From a535cd4b55d547c2fdef41707cf7e6380f7e32f8 Mon Sep 17 00:00:00 2001 From: rosspeili Date: Wed, 17 Jun 2026 13:13:53 +0300 Subject: [PATCH] feat: add Rooms-native Skillware CLI and wizard assignment Introduce optional skills list/inspect/suggest commands and custom-agent wizard skill assignment with graceful no-Skillware handling, plus tests and docs updates for the integrated skill-aware UX. --- README.md | 17 +++--- cli.py | 123 +++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 15 +++++ docs/EXAMPLES.md | 12 ++++ docs/SKILLWARE.md | 58 ++++++++++++++++++ docs/TESTING.md | 14 ++++- rooms/skills_cli.py | 108 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 60 +++++++++++++++++++ tests/test_skills_cli.py | 62 ++++++++++++++++++++ 9 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 docs/SKILLWARE.md create mode 100644 rooms/skills_cli.py create mode 100644 tests/test_skills_cli.py diff --git a/README.md b/README.md index 57fcb2d..408a5b4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - **User Profile & Identity**: Name and background provided at session start; agents treat the user as an equal room participant. - **Global Orchestrator**: A designated room moderator that fires every N turns to summarize or redirect agents, with no runaway loop risk. - **Timestamped Session Memory**: All turns, messages, and system events are tagged with precise timestamps for full auditability. +- **Optional Skillware Integration**: Assign skills per agent, execute tools lazily, and keep user-facing replies natural. ## Framework Capabilities @@ -54,9 +55,10 @@ The framework allows extreme granularity in handling session configurations: For deeper insights into how to leverage and modify the framework, please refer to our dedicated documentation guides: -- [Architecture & LiteLLM Guide](docs/ARCHITECTURE.md) - Understand how local API routing, session memory, and agent selection work. +- [Architecture & LiteLLM Guide](docs/ARCHITECTURE.md) - Understand local API routing, session memory, orchestration, and tool logging. - [Use Cases, Examples & Best Practices](docs/EXAMPLES.md) - Parameter cheat sheet, deep persona guide, scenario walkthroughs, and an edge case reference table. -- [Testing Strategy](docs/TESTING.md) - How to write and run deterministic tests for multi-agent logic. +- [Skillware Integration Guide](docs/SKILLWARE.md) - Skills CLI commands, wizard assignment flow, and optional dependency behavior. +- [Testing Strategy](docs/TESTING.md) - How to write and run deterministic tests for multi-agent and skills logic. - [Contributing Guide](CONTRIBUTING.md) - Learn how to contribute to the project, report bugs, and follow our design philosophy. - [Project Changelog](CHANGELOG.md) - Track all notable updates, fixes, and pre-release changes to the framework. @@ -69,7 +71,7 @@ Rooms/ │ ├── config.py # Pydantic Configuration Models │ ├── agent.py # Agent & LiteLLM/Custom Logic │ ├── session.py # Turn Orchestration & Memory -│ ├── settings.py # YAML settings loader (#26, #27) +│ ├── settings.py # YAML settings loader │ └── storage.py # Secure Log Serialization ├── tests/ # Unit Tests │ └── test_session.py # Logic Verification @@ -99,7 +101,7 @@ venv\Scripts\activate # Windows: venv\Scripts\activate | Unix: source venv/bin/ pip install -r requirements.txt ``` #### Optional: Long-Term Memory & RAG Support -If you plan to use vector memory features (such as long-term agent memory across sessions (#13)), you will need to install the heavier machine learning dependencies separately: +If you plan to use vector memory features, install the heavier machine learning dependencies separately: ```bash pip install -r requirements-memory.txt ``` @@ -131,10 +133,13 @@ The wizard will step you through: - Setting your user profile (name and background) - Defining the session topic and turn limits - Inviting default or custom agents with individual temperatures and system prompts +- Optionally assigning Skillware skills (and per-skill overrides) to custom agents - Optionally assigning a Global Orchestrator During a session, type `@AgentName` in any user input to force a specific agent to respond next. +For skills-specific commands and usage, see `docs/SKILLWARE.md`. + **Run Tests** ```bash # Always run via pytest with PYTHONPATH set: @@ -145,10 +150,6 @@ $env:PYTHONPATH="."; python -m pytest tests/ -v This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -## Roadmap & Progress - -Track our active progress on the [GitHub Issues](https://github.com/arpahls/Rooms/issues) board. -
ARPA Logo diff --git a/cli.py b/cli.py index e6f6b1c..602ce2b 100644 --- a/cli.py +++ b/cli.py @@ -13,6 +13,7 @@ from rooms.agent import Agent from rooms.session import Session from rooms.storage import save_transcript +from rooms.skills_cli import list_skills, inspect_skill, suggest_skills from rooms.settings import ( RoomsSettings, SettingsError, @@ -92,9 +93,52 @@ def create_custom_agent_wizard(settings: RoomsSettings, tracked_env_keys: Option config.color = Prompt.ask("CLI output color (e.g. red, green, blue, cyan, magenta, yellow)", default="blue") config.temperature = float(Prompt.ask("Generation Temperature", default=str(defaults.temperature))) + _assign_skills_in_wizard(config) return config +def _assign_skills_in_wizard(config: AgentConfig) -> None: + """Optional skill assignment step for custom agents.""" + if not Confirm.ask("Assign Skillware skills to this agent?", default=False): + return + + skills, err = list_skills() + if err: + console.print(f"[yellow]{err}[/yellow]") + if not Confirm.ask("Continue with manual skill IDs anyway?", default=False): + return + + if skills: + console.print("\n[bold green]Available Skills[/bold green]") + for item in skills: + desc = item.get("description", "").strip() + suffix = f" - {desc}" if desc else "" + console.print(f"- {item['id']}{suffix}") + + raw = Prompt.ask( + "Skill IDs to assign (comma separated, leave blank to skip)", + default="", + ).strip() + if not raw: + return + config.skills = [item.strip() for item in raw.split(",") if item.strip()] + + for skill_id in config.skills: + if Confirm.ask(f"Add runtime config overrides for {skill_id}?", default=False): + cfg_raw = Prompt.ask( + f"Enter {skill_id} overrides as key=value pairs (comma separated)", + default="", + ).strip() + overrides = {} + if cfg_raw: + for pair in [p.strip() for p in cfg_raw.split(",") if p.strip()]: + if "=" in pair: + key, value = pair.split("=", 1) + overrides[key.strip()] = value.strip() + if overrides: + config.skill_settings[skill_id] = overrides + + def main_menu(settings: RoomsSettings): console.print(Panel.fit("[bold magenta]Multi-Agent Room Framework[/bold magenta]", subtitle="Advanced Scenario Wizard")) default_personas = get_default_personas(settings) @@ -286,6 +330,65 @@ def cmd_config_reset(args: argparse.Namespace) -> int: return 0 +def cmd_skills_list(_args: argparse.Namespace) -> int: + skills, err = list_skills() + if err: + console.print(f"[yellow]{err}[/yellow]") + return 0 + if not skills: + console.print("[yellow]No skills discovered.[/yellow]") + return 0 + + console.print("[bold green]Available Skills[/bold green]") + for item in skills: + desc = item.get("description", "").strip() + suffix = f" - {desc}" if desc else "" + console.print(f"- {item['id']}{suffix}") + return 0 + + +def cmd_skills_inspect(args: argparse.Namespace) -> int: + payload, err = inspect_skill(args.skill_id) + if err: + console.print(f"[yellow]{err}[/yellow]") + return 1 + + console.print(Panel.fit(f"[bold]{payload['id']}[/bold]", title="Skill")) + if payload.get("version"): + console.print(f"[cyan]Version:[/cyan] {payload['version']}") + if payload.get("description"): + console.print(f"[cyan]Description:[/cyan] {payload['description']}") + if payload.get("inputs"): + console.print(f"[cyan]Inputs:[/cyan] {payload['inputs']}") + if payload.get("instructions"): + console.print(Rule("Skill Instructions")) + console.print(payload["instructions"]) + return 0 + + +def cmd_skills_suggest(args: argparse.Namespace) -> int: + expertise = [item.strip() for item in args.expertise.split(",") if item.strip()] + if not expertise: + console.print("[yellow]No expertise keywords provided.[/yellow]") + return 1 + skills, err = list_skills() + if err: + console.print(f"[yellow]{err}[/yellow]") + return 0 + + suggested = suggest_skills(skills, expertise) + if not suggested: + console.print("[yellow]No suggested skills matched your expertise keywords.[/yellow]") + return 0 + + console.print("[bold green]Suggested Skills[/bold green]") + for item in suggested: + desc = item.get("description", "").strip() + suffix = f" - {desc}" if desc else "" + console.print(f"- {item['id']}{suffix}") + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Multi-Agent Room Framework") parser.add_argument( @@ -303,6 +406,18 @@ def build_parser() -> argparse.ArgumentParser: reset_p.add_argument("--path", help="Specific settings file to remove") reset_p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation") + skills_parser = sub.add_parser("skills", help="Rooms-native Skillware discovery and inspection") + skills_sub = skills_parser.add_subparsers(dest="skills_cmd", required=True) + skills_sub.add_parser("list", help="List available skills") + inspect_p = skills_sub.add_parser("inspect", help="Inspect a specific skill") + inspect_p.add_argument("skill_id", help="Skill ID, e.g. finance/wallet_screening") + suggest_p = skills_sub.add_parser("suggest", help="Suggest skills from expertise keywords") + suggest_p.add_argument( + "--expertise", + required=True, + help="Comma-separated keywords, e.g. finance,risk,compliance", + ) + parser.add_argument( "--skip-preflight", action="store_true", @@ -330,6 +445,14 @@ def main(argv: Optional[List[str]] = None) -> int: if args.config_cmd == "reset": return cmd_config_reset(args) return 1 + if args.command == "skills": + if args.skills_cmd == "list": + return cmd_skills_list(args) + if args.skills_cmd == "inspect": + return cmd_skills_inspect(args) + if args.skills_cmd == "suggest": + return cmd_skills_suggest(args) + return 1 try: settings = load_settings(args.config, required=bool(args.config)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d98492..7a74640 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,8 @@ At the end of a session, the user is prompted to optionally save. Two formats ar The filename is auto-suggested from a short slug of the session topic (e.g. `bioethics_of_designer_babies.md`). System bootstrap messages are excluded from saved output. +For skill-enabled agents, transcript exports also include structured skill execution events (`role: skill`) with tool name, arguments, status, and result payload for auditability. + ## User Profile & Participant Identity The wizard captures a **user name and background** before the session starts. This is injected into the global session introduction, so all agents are explicitly informed of who the human participant is and can treat them as an equal voice in the room. @@ -99,6 +101,19 @@ If you do not want to use LiteLLM at all, the framework allows you to inject any The framework will dynamically import your file at runtime and use it exclusively for that agent's turns. Note that custom functions are executed with a timeout boundary (using the agent's configured `timeout` setting) to prevent infinite UI hangups. +## Skillware Integration + +Rooms includes optional Skillware support through native CLI and wizard flows. + +The integration is designed to: + +- preserve Rooms terminal UX and styling +- fail gracefully when Skillware is unavailable +- keep skill loading lazy and per-agent +- keep user-facing answers natural while logging structured tool events + +For command usage and setup, see `docs/SKILLWARE.md`. + ## CI/CD and Robustness To ensure the framework remains stable as it grows, we maintain a comprehensive CI/CD pipeline using **GitHub Actions**. Every contribution is automatically tested against Python 3.13 for: - **Linting**: High-standard code hygiene via `flake8`. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index a90d483..1de0135 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -230,3 +230,15 @@ If you are running the application in a CI/CD automation environment, running au ```bash python cli.py --skip-preflight ``` + +### Skills in Real Sessions + +You can assign optional skills to custom agents during wizard setup. + +Recommended flow: + +- start with one focused skill per agent +- keep expertise keywords aligned with expected tool usage +- use per-skill overrides only when needed + +For complete command reference and setup, see `docs/SKILLWARE.md`. diff --git a/docs/SKILLWARE.md b/docs/SKILLWARE.md new file mode 100644 index 0000000..d5b1471 --- /dev/null +++ b/docs/SKILLWARE.md @@ -0,0 +1,58 @@ +# Skillware Integration + +Rooms supports optional Skillware-based tool use for agents. + +## Design Principles + +- Skills are assigned per agent, not globally. +- Skillware is loaded lazily only when an agent has configured skills. +- Agent replies remain human-readable even when tools are used. +- Skill execution metadata is logged as structured session events. + +## Install (Optional) + +```bash +pip install skillware +``` + +Rooms works normally without Skillware. Skill commands and wizard steps fail gracefully with guidance. + +## Skills CLI Commands + +```bash +python cli.py skills list +python cli.py skills inspect finance/wallet_screening +python cli.py skills suggest --expertise finance,risk,compliance +``` + +## Wizard Assignment Flow + +When creating a custom agent, you can optionally: + +- assign one or more skill IDs +- add per-skill override settings (`key=value` pairs) +- skip skills entirely + +Assigned skills are stored in agent config: + +- `skills` +- `skill_settings` + +## Runtime Behavior + +- Tools are exposed to the model only for agents with configured skills. +- Skill calls are guarded by per-turn and per-session limits. +- Tool results are passed back to the model for second-pass synthesis. +- Final user-visible output remains a normal agent message, not raw JSON. + +## Session Logging and Transcripts + +Skill calls are recorded as structured `role: skill` events in session history. + +Saved transcripts include those events with: + +- agent name +- tool name +- arguments +- status +- result payload diff --git a/docs/TESTING.md b/docs/TESTING.md index 3fb7ed8..6e52141 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -25,7 +25,7 @@ PYTHONPATH=. python -m pytest tests/ -v ## Settings / CLI smoke (no Ollama) -Fast checks for [#26](https://github.com/ARPAHLS/rooms/issues/26) / [#27](https://github.com/ARPAHLS/rooms/issues/27) — no interactive wizard, no live inference: +Fast checks for settings and CLI behavior with no interactive wizard and no live inference: ```bash # Windows (PowerShell) @@ -36,7 +36,8 @@ $env:PYTHONPATH="."; python -m pytest tests/test_settings.py tests/test_cli_sett |---|---| | `tests/test_settings.py` | YAML load/validation, personas, builtin defaults; asserts `rooms.settings.example.yaml` exists in repo | | `tests/test_cli_settings_smoke.py` | `cli.py config init`, `config reset`, `--config` wiring | -| `tests/test_cli.py` | Wizard API-key env cleanup (#5) | +| `tests/test_cli.py` | Wizard env cleanup, Skills CLI command behavior, wizard skill assignment flow | +| `tests/test_skills_cli.py` | Skillware wrapper discovery/inspect normalization and suggestion matching | ## Session logic coverage @@ -57,6 +58,15 @@ $env:PYTHONPATH="."; python -m pytest tests/test_settings.py tests/test_cli_sett Run the full suite with `python -m pytest tests/ -v` (see **Running Tests** above). +## Skillware UX coverage + +Rooms includes tests for native Skillware UX: + +- `skills list` command output path (and graceful no-Skillware path) +- `skills inspect ` rendering +- `skills suggest --expertise ...` keyword matching behavior +- custom-agent wizard optional skill assignment and per-skill overrides + ## How to Write Custom Tests ### Step 1: Define a Mock Configuration diff --git a/rooms/skills_cli.py b/rooms/skills_cli.py new file mode 100644 index 0000000..d80fc2b --- /dev/null +++ b/rooms/skills_cli.py @@ -0,0 +1,108 @@ +"""Rooms-native Skillware CLI helpers.""" + +from __future__ import annotations + +import importlib +from typing import Any, Dict, List, Optional, Tuple + + +def load_skill_loader() -> Tuple[Optional[Any], Optional[str]]: + """Return SkillLoader class or a user-facing error message.""" + try: + loader_mod = importlib.import_module("skillware.core.loader") + loader = getattr(loader_mod, "SkillLoader", None) + if loader is None: + return None, "Skillware was found but SkillLoader is unavailable." + return loader, None + except Exception: + return None, "Skillware is not installed. Install with: pip install skillware" + + +def _normalize_skill_record(raw: Any) -> Dict[str, str]: + if isinstance(raw, str): + return {"id": raw, "title": raw, "description": ""} + if not isinstance(raw, dict): + text = str(raw) + return {"id": text, "title": text, "description": ""} + + skill_id = ( + raw.get("id") + or raw.get("name") + or raw.get("skill_id") + or raw.get("slug") + or raw.get("manifest", {}).get("name") + or "unknown" + ) + title = raw.get("title") or raw.get("display_name") or skill_id + description = raw.get("description") or raw.get("summary") or "" + return {"id": str(skill_id), "title": str(title), "description": str(description)} + + +def list_skills(limit: int = 200) -> Tuple[List[Dict[str, str]], Optional[str]]: + """List available skills from Skillware discovery APIs.""" + loader, err = load_skill_loader() + if err: + return [], err + + candidates = ( + "list_skills", + "discover_skills", + "discover", + "list_available_skills", + ) + for meth in candidates: + fn = getattr(loader, meth, None) + if not callable(fn): + continue + try: + result = fn() + if isinstance(result, dict): + items = list(result.values()) + else: + items = list(result or []) + normalized = [_normalize_skill_record(item) for item in items][:limit] + normalized.sort(key=lambda x: x["id"]) + return normalized, None + except Exception as exc: # noqa: BLE001 + return [], f"Skill discovery failed via Skillware: {exc}" + + return [], "Skillware is installed but no supported discovery API was found." + + +def inspect_skill(skill_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """Load one skill and return normalized inspect payload.""" + loader, err = load_skill_loader() + if err: + return None, err + + try: + bundle = loader.load_skill(skill_id) + except Exception as exc: # noqa: BLE001 + return None, f"Could not load '{skill_id}': {exc}" + + manifest = bundle.get("manifest", {}) if isinstance(bundle, dict) else {} + info = { + "id": manifest.get("name", skill_id), + "version": manifest.get("version", ""), + "description": manifest.get("description", ""), + "inputs": manifest.get("inputs", {}), + "instructions": (bundle.get("instructions", "") if isinstance(bundle, dict) else ""), + } + return info, None + + +def suggest_skills(skills: List[Dict[str, str]], expertise: List[str]) -> List[Dict[str, str]]: + """Suggest skills by lightweight keyword matching.""" + keywords = [k.strip().lower() for k in expertise if k and k.strip()] + if not keywords: + return [] + + scored: List[Tuple[int, Dict[str, str]]] = [] + for skill in skills: + haystack = f"{skill.get('id', '')} {skill.get('title', '')} {skill.get('description', '')}".lower() + score = sum(1 for kw in keywords if kw in haystack) + if score > 0: + scored.append((score, skill)) + + scored.sort(key=lambda item: (-item[0], item[1].get("id", ""))) + return [item[1] for item in scored] diff --git a/tests/test_cli.py b/tests/test_cli.py index 8fea80f..87d0b9c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,9 @@ import os +from argparse import Namespace +from unittest.mock import patch import cli +from rooms.config import AgentConfig def test_set_session_env_key_tracks_new_keys(): @@ -44,3 +47,60 @@ def test_cleanup_session_env_removes_only_tracked_keys(): assert os.environ[existing_key] == "keep-me" os.environ.pop(existing_key, None) + + +def test_cmd_skills_list_success(): + fake = [{"id": "finance/wallet_screening", "description": "Screen wallets"}] + with patch.object(cli, "list_skills", return_value=(fake, None)): + rc = cli.cmd_skills_list(Namespace()) + assert rc == 0 + + +def test_cmd_skills_list_handles_missing_skillware(): + with patch.object(cli, "list_skills", return_value=([], "Skillware is not installed")): + rc = cli.cmd_skills_list(Namespace()) + assert rc == 0 + + +def test_cmd_skills_inspect_success(): + payload = { + "id": "finance/wallet_screening", + "version": "1.0.0", + "description": "Screen wallet risk", + "inputs": {"wallet": "string"}, + "instructions": "Use carefully.", + } + with patch.object(cli, "inspect_skill", return_value=(payload, None)): + rc = cli.cmd_skills_inspect(Namespace(skill_id="finance/wallet_screening")) + assert rc == 0 + + +def test_cmd_skills_suggest_requires_expertise(): + rc = cli.cmd_skills_suggest(Namespace(expertise="")) + assert rc == 1 + + +def test_cmd_skills_suggest_success(): + available = [ + {"id": "finance/wallet_screening", "title": "Wallet Screening", "description": "risk sanctions checks"}, + {"id": "legal/contract_parser", "title": "Contract Parser", "description": "contracts"}, + ] + with patch.object(cli, "list_skills", return_value=(available, None)): + rc = cli.cmd_skills_suggest(Namespace(expertise="finance,risk")) + assert rc == 0 + + +def test_assign_skills_in_wizard_manual_ids(): + cfg = AgentConfig(name="A", system_prompt="S") + prompts = iter(["finance/wallet_screening", "mode=strict"]) + + def _ask(_text, default="", **_kwargs): + return next(prompts) + + with patch.object(cli, "list_skills", return_value=([], "Skillware missing")), \ + patch.object(cli.Confirm, "ask", side_effect=[True, True, True]), \ + patch.object(cli.Prompt, "ask", side_effect=_ask): + cli._assign_skills_in_wizard(cfg) + + assert cfg.skills == ["finance/wallet_screening"] + assert cfg.skill_settings["finance/wallet_screening"]["mode"] == "strict" diff --git a/tests/test_skills_cli.py b/tests/test_skills_cli.py new file mode 100644 index 0000000..02fe4d5 --- /dev/null +++ b/tests/test_skills_cli.py @@ -0,0 +1,62 @@ +import types + +from rooms.skills_cli import _normalize_skill_record, inspect_skill, list_skills, suggest_skills + + +def test_normalize_skill_record_string(): + out = _normalize_skill_record("finance/wallet_screening") + assert out["id"] == "finance/wallet_screening" + assert out["title"] == "finance/wallet_screening" + + +def test_list_skills_via_loader_api(monkeypatch): + class FakeLoader: + @staticmethod + def list_skills(): + return [ + {"id": "finance/wallet_screening", "description": "Wallet checks"}, + {"id": "legal/contract_parser", "description": "Parse contracts"}, + ] + + monkeypatch.setattr( + "rooms.skills_cli.importlib.import_module", + lambda _name: types.SimpleNamespace(SkillLoader=FakeLoader), + ) + skills, err = list_skills() + assert err is None + assert len(skills) == 2 + assert skills[0]["id"] == "finance/wallet_screening" + + +def test_inspect_skill_returns_manifest(monkeypatch): + class FakeLoader: + @staticmethod + def load_skill(_skill_id): + return { + "manifest": { + "name": "finance/wallet_screening", + "version": "1.2.3", + "description": "Screen wallet risk", + "inputs": {"wallet": "string"}, + }, + "instructions": "Use tool for wallet checks.", + } + + monkeypatch.setattr( + "rooms.skills_cli.importlib.import_module", + lambda _name: types.SimpleNamespace(SkillLoader=FakeLoader), + ) + payload, err = inspect_skill("finance/wallet_screening") + assert err is None + assert payload["id"] == "finance/wallet_screening" + assert payload["version"] == "1.2.3" + + +def test_suggest_skills_keyword_match(): + skills = [ + {"id": "finance/wallet_screening", "title": "Wallet Screening", "description": "Risk and sanctions"}, + {"id": "ops/log_parser", "title": "Log Parser", "description": "infra diagnostics"}, + ] + suggested = suggest_skills(skills, ["finance", "risk"]) + assert len(suggested) == 1 + assert suggested[0]["id"] == "finance/wallet_screening"