diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e880e936ab457..385a6bc1a8f77 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1741,10 +1741,10 @@ def _ensure_hermes_home_managed(home: Path): "category": "tool", }, "FAL_KEY": { - "description": "FAL API key for image generation", + "description": "FAL API key for image and video generation", "prompt": "FAL API key", "url": "https://fal.ai/", - "tools": ["image_generate"], + "tools": ["image_generate", "video_generate"], "password": True, "category": "tool", }, diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5edb227d955d0..c9863dbc8039c 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -57,6 +57,7 @@ ("code_execution", "⚡ Code Execution", "execute_code"), ("vision", "👁️ Vision / Image Analysis", "vision_analyze"), ("image_gen", "🎨 Image Generation", "image_generate"), + ("video_gen", "🎬 Video Generation", "video_generate"), ("moa", "🧠 Mixture of Agents", "mixture_of_agents"), ("tts", "🔊 Text-to-Speech", "text_to_speech"), ("skills", "📚 Skills", "list, view, manage"), @@ -325,6 +326,29 @@ def _get_plugin_toolset_keys() -> set: }, ], }, + "video_gen": { + "name": "Video Generation", + "icon": "🎬", + "providers": [ + { + "name": "Nous Subscription", + "badge": "subscription", + "tag": "Managed FAL video generation billed to your subscription", + "env_vars": [], + "requires_nous_auth": True, + "managed_nous_feature": "image_gen", + "override_env_vars": ["FAL_KEY"], + }, + { + "name": "FAL.ai", + "badge": "paid", + "tag": "Text-to-video and image-to-video via FAL-compatible endpoints", + "env_vars": [ + {"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"}, + ], + }, + ], + }, "browser": { "name": "Browser Automation", "icon": "🌐", diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 379aac2bbcfb6..e940882d746f3 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -303,6 +303,11 @@ def test_get_all_tool_names_returns_list(self): assert "web_search" in names assert "terminal" in names + def test_video_generate_is_discovered(self): + names = get_all_tool_names() + assert "video_generate" in names + assert get_toolset_for_tool("video_generate") == "video_gen" + def test_get_toolset_for_tool(self): result = get_toolset_for_tool("web_search") assert result is not None diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index 4e4289999c572..91cce274f2ffd 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -197,6 +197,11 @@ def test_all_includes_reference_existing_toolsets(self): for inc in ts["includes"]: assert inc in TOOLSETS, f"{name} includes unknown toolset '{inc}'" + def test_video_toolset_includes_video_generate(self): + assert "video_gen" in TOOLSETS + assert "video_generate" in resolve_toolset("video_gen") + assert "video_generate" in resolve_toolset("hermes-cli") + def test_hermes_platforms_share_core_tools(self): """All hermes-* platform toolsets share the same core tools. diff --git a/tests/tools/test_video_generation_tool.py b/tests/tools/test_video_generation_tool.py new file mode 100644 index 0000000000000..20d64e08d74ce --- /dev/null +++ b/tests/tools/test_video_generation_tool.py @@ -0,0 +1,121 @@ +import json +from importlib import import_module, reload + + +class _FakeDownloadResponse: + def __init__(self, body=b"fake-video-bytes"): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._body + + +def _reload_video_tool(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("FAL_KEY", "fal-test-key") + module = import_module("tools.video_generation_tool") + return reload(module) + + +def test_text_to_video_submits_default_fal_endpoint_and_saves_mp4(monkeypatch, tmp_path): + video_generation_tool = _reload_video_tool(monkeypatch, tmp_path) + captured = {} + + class FakeHandle: + def get(self): + return { + "video": { + "url": "https://v3.fal.media/files/lion/render_output.mp4", + "content_type": "video/mp4", + "file_name": "render_output.mp4", + } + } + + def fake_submit(model, arguments): + captured["model"] = model + captured["arguments"] = arguments + return FakeHandle() + + def fake_urlopen(request, timeout=None): + captured["download_url"] = request.full_url + captured["download_timeout"] = timeout + return _FakeDownloadResponse() + + monkeypatch.setattr(video_generation_tool, "_submit_fal_request", fake_submit) + monkeypatch.setattr(video_generation_tool.urllib.request, "urlopen", fake_urlopen) + + result = json.loads(video_generation_tool.video_generate_tool( + prompt="a small robot walking through rainy neon streets", + mode="text_to_video", + duration="5", + aspect_ratio="portrait", + )) + + assert result["success"] is True + assert result["provider"] == "fal" + assert result["mode"] == "text_to_video" + assert result["model"] == "fal-ai/kling-video/v2.1/master/text-to-video" + assert result["video_url"] == result["video"] + assert result["media_path"].startswith(str(tmp_path / "generated-videos")) + assert open(result["media_path"], "rb").read() == b"fake-video-bytes" + assert captured["download_url"] == result["video"] + assert captured["download_timeout"] == 300 + assert captured["arguments"] == { + "prompt": "a small robot walking through rainy neon streets", + "duration": "5", + "aspect_ratio": "9:16", + "negative_prompt": "blur, distort, and low quality", + "cfg_scale": 0.5, + } + + +def test_image_to_video_requires_image_url_and_submits_image_endpoint(monkeypatch, tmp_path): + video_generation_tool = _reload_video_tool(monkeypatch, tmp_path) + captured = {} + + class FakeHandle: + def get(self): + return {"video": {"url": "https://v3.fal.media/files/rabbit/i2v.mp4"}} + + monkeypatch.setattr(video_generation_tool, "_submit_fal_request", lambda model, arguments: captured.update({"model": model, "arguments": arguments}) or FakeHandle()) + monkeypatch.setattr(video_generation_tool.urllib.request, "urlopen", lambda request, timeout=None: _FakeDownloadResponse(b"image-to-video")) + + result = json.loads(video_generation_tool.video_generate_tool( + prompt="gentle camera push-in, leaves moving in the wind", + mode="image_to_video", + image_url="https://example.com/source.png", + duration=10, + aspect_ratio="square", + cfg_scale=0.7, + )) + + assert result["success"] is True + assert result["mode"] == "image_to_video" + assert captured["model"] == "fal-ai/kling-video/v2.1/master/image-to-video" + assert captured["arguments"] == { + "prompt": "gentle camera push-in, leaves moving in the wind", + "image_url": "https://example.com/source.png", + "duration": "10", + "aspect_ratio": "1:1", + "negative_prompt": "blur, distort, and low quality", + "cfg_scale": 0.7, + } + + +def test_image_to_video_without_image_url_returns_validation_error(monkeypatch, tmp_path): + video_generation_tool = _reload_video_tool(monkeypatch, tmp_path) + result = json.loads(video_generation_tool.video_generate_tool(prompt="slow cinematic motion", mode="image_to_video")) + assert result["success"] is False + assert result["error_type"] == "ValueError" + assert "image_url is required" in result["error"] + + +def test_check_requirements_accepts_direct_fal_key(monkeypatch, tmp_path): + video_generation_tool = _reload_video_tool(monkeypatch, tmp_path) + assert video_generation_tool.check_video_generation_requirements() is True diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py new file mode 100644 index 0000000000000..83103a6c5f2e3 --- /dev/null +++ b/tools/video_generation_tool.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Video generation tool backed by FAL-compatible video endpoints.""" + +from __future__ import annotations + +import datetime +import json +import re +import uuid +import urllib.request +from pathlib import Path +from typing import Any, Dict, Optional + +import fal_client # noqa: F401 — imported for requirement checks and direct backend availability + +from hermes_constants import get_hermes_home +from tools.image_generation_tool import _submit_fal_request, check_fal_api_key +from tools.registry import registry, tool_error + +DEFAULT_TEXT_TO_VIDEO_MODEL = "fal-ai/kling-video/v2.1/master/text-to-video" +DEFAULT_IMAGE_TO_VIDEO_MODEL = "fal-ai/kling-video/v2.1/master/image-to-video" +DEFAULT_DURATION = "5" +DEFAULT_ASPECT_RATIO = "landscape" +DEFAULT_NEGATIVE_PROMPT = "blur, distort, and low quality" +DEFAULT_CFG_SCALE = 0.5 +DEFAULT_DOWNLOAD_TIMEOUT = 300 + +MODE_ALIASES = { + "text": "text_to_video", + "txt2vid": "text_to_video", + "t2v": "text_to_video", + "text_to_video": "text_to_video", + "image": "image_to_video", + "img2vid": "image_to_video", + "i2v": "image_to_video", + "image_to_video": "image_to_video", +} + +ASPECT_RATIO_MAP = { + "landscape": "16:9", + "wide": "16:9", + "16:9": "16:9", + "portrait": "9:16", + "vertical": "9:16", + "9:16": "9:16", + "square": "1:1", + "1:1": "1:1", +} + +VALID_DURATIONS = {"5", "10"} + + +def _normalize_mode(mode: str) -> str: + normalized = str(mode or "text_to_video").strip().lower().replace("-", "_") + if normalized not in MODE_ALIASES: + raise ValueError("mode must be one of: text_to_video, image_to_video") + return MODE_ALIASES[normalized] + + +def _normalize_aspect_ratio(aspect_ratio: str) -> str: + normalized = str(aspect_ratio or DEFAULT_ASPECT_RATIO).strip().lower() + if normalized not in ASPECT_RATIO_MAP: + raise ValueError("aspect_ratio must be one of: landscape, portrait, square, 16:9, 9:16, 1:1") + return ASPECT_RATIO_MAP[normalized] + + +def _normalize_duration(duration: Any) -> str: + normalized = str(duration or DEFAULT_DURATION).strip() + if normalized.endswith("s"): + normalized = normalized[:-1] + if normalized not in VALID_DURATIONS: + raise ValueError("duration must be 5 or 10 seconds for the default FAL video endpoints") + return normalized + + +def _normalize_cfg_scale(cfg_scale: Any) -> float: + try: + value = float(cfg_scale) + except (TypeError, ValueError) as exc: + raise ValueError("cfg_scale must be a number between 0 and 1") from exc + if value < 0 or value > 1: + raise ValueError("cfg_scale must be between 0 and 1") + return value + + +def _model_for_mode(mode: str) -> str: + """Resolve the FAL-compatible video model for the requested mode. + + Env vars are intentionally kept as low-level escape hatches so admins can + point the generic tool at any FAL-compatible endpoint without changing the + agent-facing schema. + """ + import os + + if mode == "image_to_video": + return os.getenv("VIDEO_FAL_IMAGE_TO_VIDEO_MODEL", DEFAULT_IMAGE_TO_VIDEO_MODEL).strip() or DEFAULT_IMAGE_TO_VIDEO_MODEL + return os.getenv("VIDEO_FAL_TEXT_TO_VIDEO_MODEL", DEFAULT_TEXT_TO_VIDEO_MODEL).strip() or DEFAULT_TEXT_TO_VIDEO_MODEL + + +def _safe_video_filename(video_url: str, file_name: Optional[str] = None) -> str: + candidate = file_name or Path(str(video_url).split("?", 1)[0]).name + candidate = re.sub(r"[^A-Za-z0-9._-]+", "_", candidate or "") + if not candidate or "." not in candidate: + candidate = f"video_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}.mp4" + if not candidate.lower().endswith(".mp4"): + candidate = f"{candidate}.mp4" + return candidate + + +def _download_video(video_url: str, *, file_name: Optional[str] = None) -> str: + out_dir = get_hermes_home() / "generated-videos" + out_dir.mkdir(parents=True, exist_ok=True) + base_name = _safe_video_filename(video_url, file_name=file_name) + out_path = out_dir / base_name + if out_path.exists(): + out_path = out_dir / f"{out_path.stem}_{uuid.uuid4().hex[:8]}{out_path.suffix}" + + request = urllib.request.Request(video_url, headers={"User-Agent": "Hermes/1.0"}) + with urllib.request.urlopen(request, timeout=DEFAULT_DOWNLOAD_TIMEOUT) as response: + out_path.write_bytes(response.read()) + return str(out_path) + + +def _extract_video_info(result: Dict[str, Any]) -> Dict[str, Optional[str]]: + video = result.get("video") if isinstance(result, dict) else None + if isinstance(video, dict): + return { + "url": video.get("url"), + "file_name": video.get("file_name"), + "content_type": video.get("content_type"), + } + if isinstance(video, str): + return {"url": video, "file_name": None, "content_type": None} + if isinstance(result, dict) and isinstance(result.get("video_url"), str): + return {"url": result.get("video_url"), "file_name": None, "content_type": None} + return {"url": None, "file_name": None, "content_type": None} + + +def video_generate_tool( + prompt: str, + mode: str = "text_to_video", + image_url: Optional[str] = None, + duration: Any = DEFAULT_DURATION, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + negative_prompt: str = DEFAULT_NEGATIVE_PROMPT, + cfg_scale: Any = DEFAULT_CFG_SCALE, +) -> str: + """Generate a short MP4 video through a FAL-compatible video backend.""" + start_time = datetime.datetime.now() + try: + prompt = str(prompt or "").strip() + if not prompt: + raise ValueError("prompt is required for video generation") + if not check_fal_api_key(): + raise ValueError("FAL_KEY environment variable not set and managed FAL gateway is unavailable") + + normalized_mode = _normalize_mode(mode) + normalized_duration = _normalize_duration(duration) + normalized_aspect_ratio = _normalize_aspect_ratio(aspect_ratio) + normalized_cfg_scale = _normalize_cfg_scale(cfg_scale) + + arguments: Dict[str, Any] = { + "prompt": prompt, + "duration": normalized_duration, + "aspect_ratio": normalized_aspect_ratio, + "negative_prompt": str(negative_prompt or DEFAULT_NEGATIVE_PROMPT), + "cfg_scale": normalized_cfg_scale, + } + + if normalized_mode == "image_to_video": + image_url = str(image_url or "").strip() + if not image_url: + raise ValueError("image_url is required for image_to_video mode") + arguments["image_url"] = image_url + + model = _model_for_mode(normalized_mode) + handler = _submit_fal_request(model, arguments) + raw_result = handler.get() + video_info = _extract_video_info(raw_result if isinstance(raw_result, dict) else {}) + video_url = video_info.get("url") + if not video_url: + raise ValueError("Invalid response from FAL video API - no video URL returned") + + response_data: Dict[str, Any] = { + "success": True, + "provider": "fal", + "mode": normalized_mode, + "model": model, + "video": video_url, + "video_url": video_url, + "media_path": None, + "generation_time": (datetime.datetime.now() - start_time).total_seconds(), + } + + try: + response_data["media_path"] = _download_video(video_url, file_name=video_info.get("file_name")) + except Exception as download_error: # Keep remote URL usable if local download fails. + response_data["download_error"] = str(download_error) + + return json.dumps(response_data, indent=2, ensure_ascii=False) + except Exception as exc: + return json.dumps( + { + "success": False, + "video": None, + "video_url": None, + "media_path": None, + "error": str(exc), + "error_type": type(exc).__name__, + "generation_time": (datetime.datetime.now() - start_time).total_seconds(), + }, + indent=2, + ensure_ascii=False, + ) + + +def check_video_generation_requirements() -> bool: + try: + import fal_client as _fal_client # noqa: F401 + except ImportError: + return False + return check_fal_api_key() + + +VIDEO_GENERATE_SCHEMA = { + "name": "video_generate", + "description": "Generate short MP4 videos through FAL-compatible video models. Supports text-to-video and image-to-video. Returns a video URL and a local media_path when the MP4 can be downloaded.", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The text prompt describing the video, camera motion, subject motion, style, and mood.", + }, + "mode": { + "type": "string", + "enum": ["text_to_video", "image_to_video"], + "description": "Use text_to_video for prompt-only generation, or image_to_video to animate a source image.", + "default": "text_to_video", + }, + "image_url": { + "type": "string", + "description": "Source image URL. Required when mode is image_to_video.", + }, + "duration": { + "type": "string", + "enum": ["5", "10"], + "description": "Video length in seconds for the default FAL video endpoints.", + "default": "5", + }, + "aspect_ratio": { + "type": "string", + "enum": ["landscape", "portrait", "square"], + "description": "Video frame shape. landscape=16:9, portrait=9:16, square=1:1.", + "default": "landscape", + }, + "negative_prompt": { + "type": "string", + "description": "What to avoid in the generated video.", + "default": DEFAULT_NEGATIVE_PROMPT, + }, + "cfg_scale": { + "type": "number", + "description": "Prompt adherence for default Kling endpoints. Range 0 to 1.", + "default": DEFAULT_CFG_SCALE, + }, + }, + "required": ["prompt"], + }, +} + + +def _handle_video_generate(args, **kw): + prompt = args.get("prompt", "") + if not prompt: + return tool_error("prompt is required for video generation") + return video_generate_tool( + prompt=prompt, + mode=args.get("mode", "text_to_video"), + image_url=args.get("image_url"), + duration=args.get("duration", DEFAULT_DURATION), + aspect_ratio=args.get("aspect_ratio", DEFAULT_ASPECT_RATIO), + negative_prompt=args.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT), + cfg_scale=args.get("cfg_scale", DEFAULT_CFG_SCALE), + ) + + +registry.register( + name="video_generate", + toolset="video_gen", + schema=VIDEO_GENERATE_SCHEMA, + handler=_handle_video_generate, + check_fn=check_video_generation_requirements, + requires_env=[], + is_async=False, + emoji="🎬", +) diff --git a/toolsets.py b/toolsets.py index ee067aa13e35d..42a3eeedd6f20 100644 --- a/toolsets.py +++ b/toolsets.py @@ -15,10 +15,10 @@ Usage: from toolsets import get_toolset, resolve_toolset, get_all_toolsets - + # Get tools for a specific toolset tools = get_toolset("research") - + # Resolve a toolset to get all tool names (including from composed toolsets) all_tools = resolve_toolset("full_stack") """ @@ -35,8 +35,8 @@ "terminal", "process", # File manipulation "read_file", "write_file", "patch", "search_files", - # Vision + image generation - "vision_analyze", "image_generate", + # Vision + media generation + "vision_analyze", "image_generate", "video_generate", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation @@ -72,43 +72,49 @@ "tools": ["web_search", "web_extract"], "includes": [] # No other toolsets included }, - + "search": { "description": "Web search only (no content extraction/scraping)", "tools": ["web_search"], "includes": [] }, - + "vision": { "description": "Image analysis and vision tools", "tools": ["vision_analyze"], "includes": [] }, - + "image_gen": { "description": "Creative generation tools (images)", "tools": ["image_generate"], "includes": [] }, - + + "video_gen": { + "description": "Creative generation tools (short videos)", + "tools": ["video_generate"], + "includes": [] + }, + "terminal": { "description": "Terminal/command execution and process management tools", "tools": ["terminal", "process"], "includes": [] }, - + "moa": { "description": "Advanced reasoning and problem-solving tools", "tools": ["mixture_of_agents"], "includes": [] }, - + "skills": { "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", "tools": ["skills_list", "skill_view", "skill_manage"], "includes": [] }, - + "browser": { "description": "Browser automation for web interaction (navigate, click, type, scroll, iframes, hold-click) with web search for finding URLs", "tools": [ @@ -120,19 +126,19 @@ ], "includes": [] }, - + "cronjob": { "description": "Cronjob management tool - create, list, update, pause, resume, remove, and trigger scheduled tasks", "tools": ["cronjob"], "includes": [] }, - + "messaging": { "description": "Cross-platform messaging: send messages to Telegram, Discord, Slack, SMS, etc.", "tools": ["send_message"], "includes": [] }, - + "rl": { "description": "RL training tools for running reinforcement learning on Tinker-Atropos", "tools": [ @@ -144,49 +150,49 @@ ], "includes": [] }, - + "file": { "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)", "tools": ["read_file", "write_file", "patch", "search_files"], "includes": [] }, - + "tts": { "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI", "tools": ["text_to_speech"], "includes": [] }, - + "todo": { "description": "Task planning and tracking for multi-step work", "tools": ["todo"], "includes": [] }, - + "memory": { "description": "Persistent memory across sessions (personal notes + user profile)", "tools": ["memory"], "includes": [] }, - + "session_search": { "description": "Search and recall past conversations with summarization", "tools": ["session_search"], "includes": [] }, - + "clarify": { "description": "Ask the user clarifying questions (multiple-choice or open-ended)", "tools": ["clarify"], "includes": [] }, - + "code_execution": { "description": "Run Python scripts that call tools programmatically (reduces LLM round trips)", "tools": ["execute_code"], "includes": [] }, - + "delegation": { "description": "Spawn subagents with isolated context for complex subtasks", "tools": ["delegate_task"], @@ -252,19 +258,19 @@ # Scenario-specific toolsets - + "debugging": { "description": "Debugging and troubleshooting toolkit", "tools": ["terminal", "process"], "includes": ["web", "file"] # For searching error messages and solutions, and file operations }, - + "safe": { "description": "Safe toolkit without terminal access", "tools": [], "includes": ["web", "vision", "image_gen"] }, - + # ========================================================================== # Full Hermes toolsets (CLI + messaging platforms) # @@ -300,8 +306,8 @@ "terminal", "process", # File manipulation "read_file", "write_file", "patch", "search_files", - # Vision + image generation - "vision_analyze", "image_generate", + # Vision + media generation + "vision_analyze", "image_generate", "video_generate", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation @@ -323,7 +329,7 @@ ], "includes": [] }, - + "hermes-cli": { "description": "Full interactive CLI toolset - all default tools plus cronjob management", "tools": _HERMES_CORE_TOOLS, @@ -346,7 +352,7 @@ "tools": _HERMES_CORE_TOOLS, "includes": [] }, - + "hermes-discord": { "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", "tools": _HERMES_CORE_TOOLS + [ @@ -355,19 +361,19 @@ ], "includes": [] }, - + "hermes-whatsapp": { "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", "tools": _HERMES_CORE_TOOLS, "includes": [] }, - + "hermes-slack": { "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", "tools": _HERMES_CORE_TOOLS, "includes": [] }, - + "hermes-signal": { "description": "Signal bot toolset - encrypted messaging platform (full access)", "tools": _HERMES_CORE_TOOLS, @@ -483,10 +489,10 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]: """ Get a toolset definition by name. - + Args: name (str): Name of the toolset - + Returns: Dict: Toolset definition with description, tools, and includes None: If toolset not found @@ -529,20 +535,20 @@ def get_toolset(name: str) -> Optional[Dict[str, Any]]: def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: """ Recursively resolve a toolset to get all tool names. - + This function handles toolset composition by recursively resolving included toolsets and combining all tools. - + Args: name (str): Name of the toolset to resolve visited (Set[str]): Set of already visited toolsets (for cycle detection) - + Returns: List[str]: List of all tool names in the toolset """ if visited is None: visited = set() - + # Special aliases that represent all tools across every toolset # This ensures future toolsets are automatically included without changes. if name in {"all", "*"}: @@ -596,26 +602,26 @@ def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: for included_name in toolset.get("includes", []): included_tools = resolve_toolset(included_name, visited) tools.update(included_tools) - + return sorted(tools) def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: """ Resolve multiple toolsets and combine their tools. - + Args: toolset_names (List[str]): List of toolset names to resolve - + Returns: List[str]: Combined list of all tool names (deduplicated) """ all_tools = set() - + for name in toolset_names: tools = resolve_toolset(name) all_tools.update(tools) - + return sorted(all_tools) @@ -650,7 +656,7 @@ def get_all_toolsets() -> Dict[str, Dict[str, Any]]: Get all available toolsets with their definitions. Includes both statically-defined toolsets and plugin-registered ones. - + Returns: Dict: All toolset definitions """ @@ -675,7 +681,7 @@ def get_toolset_names() -> List[str]: Get names of all available toolsets (excluding aliases). Includes plugin-registered toolset names. - + Returns: List[str]: List of toolset names """ @@ -696,10 +702,10 @@ def get_toolset_names() -> List[str]: def validate_toolset(name: str) -> bool: """ Check if a toolset name is valid. - + Args: name (str): Toolset name to validate - + Returns: bool: True if valid, False otherwise """ @@ -721,7 +727,7 @@ def create_custom_toolset( ) -> None: """ Create a custom toolset at runtime. - + Args: name (str): Name for the new toolset description (str): Description of the toolset @@ -740,19 +746,19 @@ def create_custom_toolset( def get_toolset_info(name: str) -> Dict[str, Any]: """ Get detailed information about a toolset including resolved tools. - + Args: name (str): Toolset name - + Returns: Dict: Detailed toolset information """ toolset = get_toolset(name) if not toolset: return None - + resolved_tools = resolve_toolset(name) - + return { "name": name, "description": toolset["description"], @@ -769,7 +775,7 @@ def get_toolset_info(name: str) -> Dict[str, Any]: if __name__ == "__main__": print("Toolsets System Demo") print("=" * 60) - + print("\nAvailable Toolsets:") print("-" * 40) for name, toolset in get_all_toolsets().items(): @@ -777,20 +783,20 @@ def get_toolset_info(name: str) -> Dict[str, Any]: composite = "[composite]" if info["is_composite"] else "[leaf]" print(f" {composite} {name:20} - {toolset['description']}") print(f" Tools: {len(info['resolved_tools'])} total") - + print("\nToolset Resolution Examples:") print("-" * 40) for name in ["web", "terminal", "safe", "debugging"]: tools = resolve_toolset(name) print(f"\n {name}:") print(f" Resolved to {len(tools)} tools: {', '.join(sorted(tools))}") - + print("\nMultiple Toolset Resolution:") print("-" * 40) combined = resolve_multiple_toolsets(["web", "vision", "terminal"]) print(" Combining ['web', 'vision', 'terminal']:") print(f" Result: {', '.join(sorted(combined))}") - + print("\nCustom Toolset Creation:") print("-" * 40) create_custom_toolset(