From b53f82aaad1337d5f854a00ac9a2692686fac8cd Mon Sep 17 00:00:00 2001 From: rosspeili Date: Wed, 17 Jun 2026 12:35:06 +0300 Subject: [PATCH 1/2] feat: add lazy per-agent Skillware runtime plumbing Introduce optional per-agent skills with lazy Skillware loading, execution guardrails, and a two-pass tool-call synthesis flow while preserving existing no-skill behavior. Add focused runtime tests and YAML examples for persona-level skill assignments. --- rooms.settings.example.yaml | 4 + rooms/agent.py | 63 ++++++++- rooms/config.py | 7 +- rooms/settings.py | 4 + rooms/skills_runtime.py | 170 ++++++++++++++++++++++ tests/test_skills_runtime.py | 264 +++++++++++++++++++++++++++++++++++ 6 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 rooms/skills_runtime.py create mode 100644 tests/test_skills_runtime.py diff --git a/rooms.settings.example.yaml b/rooms.settings.example.yaml index 413d217..b9fccb8 100644 --- a/rooms.settings.example.yaml +++ b/rooms.settings.example.yaml @@ -33,3 +33,7 @@ use_shipped_personas: true # model: null # temperature: null # color: "magenta" +# skills: ["compliance/tos_evaluator"] +# skill_settings: +# compliance/tos_evaluator: +# mode: "strict" diff --git a/rooms/agent.py b/rooms/agent.py index fec3a81..a6b2bd7 100644 --- a/rooms/agent.py +++ b/rooms/agent.py @@ -4,8 +4,10 @@ import sys import os import concurrent.futures +import json from typing import Optional, List, Dict, Any from .config import AgentConfig, ModelType +from .skills_runtime import SkillRuntime, build_tool_messages # Optional: Disable extreme litellm verbosity for normal usage litellm.suppress_debug_info = True @@ -22,6 +24,7 @@ def __init__(self, config: AgentConfig): self.model_type = config.model_type self.system_prompt = config.system_prompt self.expertise = config.expertise + self._skill_runtime = SkillRuntime(config) def _execute_custom_function(self, messages: List[Dict[str, str]]) -> str: """Dynamically loads and invokes a custom python function for inference.""" @@ -69,6 +72,14 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par full_system_prompt = self.config.system_prompt if self.config.custom_instructions: full_system_prompt += f"\n\nADDITIONAL INSTRUCTIONS FOR THIS SESSION:\n{self.config.custom_instructions}" + if self._skill_runtime.has_skills: + skill_instructions = self._skill_runtime.get_combined_instructions() + if skill_instructions: + full_system_prompt += ( + "\n\nAVAILABLE TOOLS:\n" + "You may call tools when needed and then summarize outputs clearly for the user.\n\n" + f"{skill_instructions}" + ) messages = [{"role": "system", "content": full_system_prompt}] messages.extend(context_messages) @@ -88,9 +99,59 @@ def generate_response(self, context_messages: List[Dict[str, str]], override_par litellm_params["timeout"] = self.config.timeout litellm_params.update(params) + tools = self._skill_runtime.get_tools() + if self._skill_runtime.has_skills and self._skill_runtime.load_error: + return f"[Error: {self._skill_runtime.load_error}]" + if tools: + litellm_params["tools"] = tools + litellm_params["tool_choice"] = "auto" response = litellm.completion(**litellm_params) - return response.choices[0].message.content.strip() + first_message = response.choices[0].message + tool_calls = list(getattr(first_message, "tool_calls", []) or []) + + if tools and tool_calls: + if len(tool_calls) > self.config.max_skill_calls_per_turn: + return ( + "[Error: Model requested too many tool calls in one turn " + f"({len(tool_calls)} > {self.config.max_skill_calls_per_turn})]" + ) + + tool_call_payload: List[Dict[str, Any]] = [] + tool_results: List[Dict[str, Any]] = [] + for tc in tool_calls: + func = getattr(tc, "function", None) + tool_name = getattr(func, "name", "") + raw_args = getattr(func, "arguments", "") or "{}" + try: + parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) + except Exception: # noqa: BLE001 + parsed_args = {} + execution = self._skill_runtime.execute_tool(tool_name, parsed_args, self.config.timeout) + + tc_id = getattr(tc, "id", f"call_{len(tool_call_payload)}") + tool_call_payload.append( + { + "id": tc_id, + "type": "function", + "function": {"name": tool_name, "arguments": json.dumps(parsed_args, ensure_ascii=True)}, + } + ) + tool_results.append( + { + "tool_call_id": tc_id, + "tool_name": tool_name, + "payload": execution, + } + ) + + messages.extend(build_tool_messages(tool_call_payload, tool_results)) + second_params = dict(litellm_params) + second_params["messages"] = messages + second_response = litellm.completion(**second_params) + return (second_response.choices[0].message.content or "").strip() + + return (first_message.content or "").strip() except litellm.Timeout as e: logger.error(f"Timeout logic executed for agent '{self.name}' on model '{self.model}': {e}") diff --git a/rooms/config.py b/rooms/config.py index 0f7dad7..d94a456 100644 --- a/rooms/config.py +++ b/rooms/config.py @@ -1,5 +1,5 @@ import enum -from typing import List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field class SessionType(str, enum.Enum): @@ -24,6 +24,11 @@ class AgentConfig(BaseModel): custom_function_path: Optional[str] = Field(default=None, description="Path to .py file if model_type is custom_function") custom_function_name: Optional[str] = Field(default=None, description="Name of the python function to call") custom_instructions: Optional[str] = Field(None, description="Per session custom instructions from the user") + skills: List[str] = Field(default_factory=list, description="Optional Skillware skill IDs assigned to this agent") + skill_settings: Dict[str, Dict[str, Any]] = Field(default_factory=dict, description="Optional per-skill runtime config overrides") + max_skill_calls_per_turn: int = Field(default=3, ge=0, description="Maximum tool calls allowed within one agent turn") + max_skill_calls_per_session: int = Field(default=20, ge=0, description="Maximum tool calls allowed for this agent instance") + skill_timeout: Optional[int] = Field(default=None, ge=1, description="Optional timeout in seconds for skill execution") class SessionConfig(BaseModel): topic: str = Field(..., description="The main topic or problem for this session") diff --git a/rooms/settings.py b/rooms/settings.py index 53ccf13..727ad8d 100644 --- a/rooms/settings.py +++ b/rooms/settings.py @@ -85,6 +85,8 @@ class PersonaSettings(BaseModel): model: Optional[str] = None temperature: Optional[float] = None color: str = "blue" + skills: List[str] = Field(default_factory=list) + skill_settings: Dict[str, Dict[str, object]] = Field(default_factory=dict) class RoomsSettings(BaseModel): @@ -170,6 +172,8 @@ def persona_settings_to_agent_config(persona: PersonaSettings, defaults: Default temperature=persona.temperature if persona.temperature is not None else defaults.temperature, timeout=defaults.timeout, color=persona.color, + skills=persona.skills, + skill_settings=persona.skill_settings, ) diff --git a/rooms/skills_runtime.py b/rooms/skills_runtime.py new file mode 100644 index 0000000..1b6d7ef --- /dev/null +++ b/rooms/skills_runtime.py @@ -0,0 +1,170 @@ +"""Lazy Skillware runtime integration for Rooms agents.""" + +from __future__ import annotations + +import concurrent.futures +import importlib +import inspect +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from .config import AgentConfig + +logger = logging.getLogger(__name__) + + +@dataclass +class SkillEntry: + skill_id: str + tool_name: str + instructions: str + instance: Any + tool_def: Dict[str, Any] + + +class SkillRuntime: + """Loads and executes skills assigned to one agent lazily.""" + + def __init__(self, config: AgentConfig): + self.config = config + self._loaded = False + self._load_error: Optional[str] = None + self._entries: List[SkillEntry] = [] + self._by_tool_name: Dict[str, SkillEntry] = {} + self._calls_made = 0 + + @property + def has_skills(self) -> bool: + return bool(self.config.skills) + + @property + def load_error(self) -> Optional[str]: + return self._load_error + + @property + def calls_made(self) -> int: + return self._calls_made + + def get_tools(self) -> List[Dict[str, Any]]: + if not self.has_skills: + return [] + self._ensure_loaded() + return [entry.tool_def for entry in self._entries] + + def get_combined_instructions(self) -> str: + if not self.has_skills: + return "" + self._ensure_loaded() + blocks = [entry.instructions.strip() for entry in self._entries if entry.instructions.strip()] + if not blocks: + return "" + return "\n\n".join(blocks) + + def execute_tool(self, tool_name: str, args: Dict[str, Any], timeout_s: int) -> Dict[str, Any]: + self._ensure_loaded() + if self._load_error: + return {"ok": False, "error": self._load_error, "tool_name": tool_name} + + if self.config.max_skill_calls_per_session >= 0 and self._calls_made >= self.config.max_skill_calls_per_session: + return { + "ok": False, + "error": f"Max skill calls per session reached ({self.config.max_skill_calls_per_session})", + "tool_name": tool_name, + } + + entry = self._by_tool_name.get(tool_name) + if not entry: + return {"ok": False, "error": f"Unknown tool call '{tool_name}' for agent", "tool_name": tool_name} + + effective_timeout = self.config.skill_timeout or timeout_s + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(entry.instance.execute, args) + try: + result = future.result(timeout=effective_timeout) + self._calls_made += 1 + return {"ok": True, "tool_name": tool_name, "skill_id": entry.skill_id, "result": result} + except concurrent.futures.TimeoutError: + return { + "ok": False, + "tool_name": tool_name, + "skill_id": entry.skill_id, + "error": f"Skill execution timed out after {effective_timeout}s", + } + except Exception as exc: # noqa: BLE001 + logger.error("Skill execute failed for %s: %s", entry.skill_id, exc) + return {"ok": False, "tool_name": tool_name, "skill_id": entry.skill_id, "error": str(exc)} + + def _ensure_loaded(self) -> None: + if self._loaded or not self.has_skills: + return + self._loaded = True + try: + loader_mod = importlib.import_module("skillware.core.loader") + skill_loader = getattr(loader_mod, "SkillLoader") + except Exception as exc: # noqa: BLE001 + self._load_error = ( + "Skillware is not installed or could not be imported. " + "Install with: pip install skillware" + ) + logger.warning("Skillware import failed: %s", exc) + return + + for skill_id in self.config.skills: + try: + bundle = skill_loader.load_skill(skill_id) + tool_def = skill_loader.to_openai_tool(bundle) + tool_name = tool_def.get("function", {}).get("name", "") + skill_class = self._pick_skill_class(bundle.get("module")) + if skill_class is None: + raise ValueError(f"No executable skill class found for '{skill_id}'") + overrides = self.config.skill_settings.get(skill_id, {}) + instance = skill_class(config=overrides) + entry = SkillEntry( + skill_id=skill_id, + tool_name=tool_name, + instructions=bundle.get("instructions", ""), + instance=instance, + tool_def=tool_def, + ) + self._entries.append(entry) + self._by_tool_name[tool_name] = entry + except Exception as exc: # noqa: BLE001 + self._load_error = f"Failed loading skill '{skill_id}': {exc}" + logger.error("Skill load failed for %s: %s", skill_id, exc) + return + + @staticmethod + def _pick_skill_class(module: Any) -> Optional[type]: + if module is None: + return None + candidates: List[type] = [] + for _, value in inspect.getmembers(module, inspect.isclass): + if value.__module__ != module.__name__: + continue + if value.__name__.startswith("_"): + continue + if hasattr(value, "execute") and callable(getattr(value, "execute")): + candidates.append(value) + if not candidates: + return None + named = [c for c in candidates if c.__name__.endswith("Skill")] + return named[0] if named else candidates[0] + + +def build_tool_messages(tool_calls: List[Dict[str, Any]], tool_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Build OpenAI-compatible tool messages for second-pass synthesis.""" + messages: List[Dict[str, Any]] = [] + if tool_calls: + messages.append({"role": "assistant", "content": "", "tool_calls": tool_calls}) + for result in tool_results: + messages.append( + { + "role": "tool", + "tool_call_id": result["tool_call_id"], + "name": result["tool_name"], + "content": json.dumps(result["payload"], ensure_ascii=True), + } + ) + return messages diff --git a/tests/test_skills_runtime.py b/tests/test_skills_runtime.py new file mode 100644 index 0000000..3bfed9b --- /dev/null +++ b/tests/test_skills_runtime.py @@ -0,0 +1,264 @@ +import types + +from rooms.agent import Agent +from rooms.config import AgentConfig +from rooms.skills_runtime import SkillRuntime + + +def _build_fake_skill_module(): + module = types.ModuleType("fake_skill_module") + + class FakeSkill: + __module__ = "fake_skill_module" + + def __init__(self, config=None): + self.config = config or {} + + def execute(self, params): + return {"echo": params, "cfg": self.config} + + module.FakeSkill = FakeSkill + return module + + +def _build_fake_loader_module(): + module = _build_fake_skill_module() + + class FakeLoader: + @staticmethod + def load_skill(skill_id): + return { + "module": module, + "manifest": {"name": skill_id}, + "instructions": "Use tool carefully.", + "card": {}, + } + + @staticmethod + def to_openai_tool(bundle): + skill_id = bundle["manifest"]["name"] + return { + "type": "function", + "function": { + "name": skill_id.replace("/", "_"), + "description": "fake tool", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + }, + }, + } + + return types.SimpleNamespace(SkillLoader=FakeLoader) + + +def test_runtime_lazy_loading_skips_import_when_no_skills(monkeypatch): + cfg = AgentConfig(name="A", system_prompt="S", skills=[]) + runtime = SkillRuntime(cfg) + + def _should_not_import(_name): + raise AssertionError("Skillware import should not run") + + monkeypatch.setattr("rooms.skills_runtime.importlib.import_module", _should_not_import) + assert runtime.get_tools() == [] + assert runtime.get_combined_instructions() == "" + + +def test_runtime_reports_missing_skillware(monkeypatch): + cfg = AgentConfig(name="A", system_prompt="S", skills=["compliance/tos_evaluator"]) + runtime = SkillRuntime(cfg) + + def _missing(_name): + raise ModuleNotFoundError("skillware unavailable") + + monkeypatch.setattr("rooms.skills_runtime.importlib.import_module", _missing) + assert runtime.get_tools() == [] + assert runtime.load_error is not None + assert "Install with: pip install skillware" in runtime.load_error + + +def test_runtime_enforces_session_call_limit(monkeypatch): + cfg = AgentConfig( + name="A", + system_prompt="S", + skills=["compliance/tos_evaluator"], + max_skill_calls_per_session=0, + ) + runtime = SkillRuntime(cfg) + monkeypatch.setattr("rooms.skills_runtime.importlib.import_module", lambda _name: _build_fake_loader_module()) + runtime.get_tools() + result = runtime.execute_tool("compliance_tos_evaluator", {"q": "hello"}, timeout_s=5) + assert result["ok"] is False + assert "Max skill calls per session reached" in result["error"] + + +def test_agent_executes_tool_and_returns_synthesized_reply(monkeypatch): + calls = [] + monkeypatch.setattr("rooms.skills_runtime.importlib.import_module", lambda _name: _build_fake_loader_module()) + + class _Function: + def __init__(self, name, arguments): + self.name = name + self.arguments = arguments + + class _ToolCall: + def __init__(self, tc_id, name, arguments): + self.id = tc_id + self.function = _Function(name, arguments) + + class _Message: + def __init__(self, content, tool_calls=None): + self.content = content + self.tool_calls = tool_calls or [] + + class _Choice: + def __init__(self, message): + self.message = message + + class _Response: + def __init__(self, message): + self.choices = [_Choice(message)] + + def _fake_completion(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + return _Response( + _Message( + "", + tool_calls=[_ToolCall("call_1", "compliance_tos_evaluator", '{"q":"risk"}')], + ) + ) + return _Response(_Message("Readable final answer")) + + monkeypatch.setattr("rooms.agent.litellm.completion", _fake_completion) + agent = Agent( + AgentConfig( + name="A", + system_prompt="S", + skills=["compliance/tos_evaluator"], + skill_settings={"compliance/tos_evaluator": {"mode": "strict"}}, + ) + ) + + result = agent.generate_response(context_messages=[]) + assert result == "Readable final answer" + assert len(calls) == 2 + assert "tools" in calls[0] + second_messages = calls[1]["messages"] + assert any(m.get("role") == "tool" for m in second_messages) + + +def test_wallet_screening_skill_flow_returns_natural_language(monkeypatch): + """Simulate a flagged wallet check and ensure final output is natural language.""" + bad_wallet = "0x1111111111111111111111111111111111111111" + tool_name = "finance_wallet_screening" + calls = [] + + module = types.ModuleType("wallet_skill_module") + + class WalletScreeningSkill: + __module__ = "wallet_skill_module" + + def __init__(self, config=None): + self.config = config or {} + + def execute(self, params): + wallet = params.get("wallet", "") + return { + "wallet": wallet, + "risk_level": "high", + "flagged": wallet == bad_wallet, + "reason": "Listed in sanctions dataset", + } + + module.WalletScreeningSkill = WalletScreeningSkill + + class FakeLoader: + @staticmethod + def load_skill(skill_id): + return { + "module": module, + "manifest": {"name": skill_id}, + "instructions": "Use this tool to screen wallets for risk flags.", + "card": {}, + } + + @staticmethod + def to_openai_tool(bundle): + return { + "type": "function", + "function": { + "name": tool_name, + "description": "Screen a wallet for risk", + "parameters": { + "type": "object", + "properties": {"wallet": {"type": "string"}}, + "required": ["wallet"], + }, + }, + } + + monkeypatch.setattr("rooms.skills_runtime.importlib.import_module", lambda _name: types.SimpleNamespace(SkillLoader=FakeLoader)) + + class _Function: + def __init__(self, name, arguments): + self.name = name + self.arguments = arguments + + class _ToolCall: + def __init__(self, tc_id, name, arguments): + self.id = tc_id + self.function = _Function(name, arguments) + + class _Message: + def __init__(self, content, tool_calls=None): + self.content = content + self.tool_calls = tool_calls or [] + + class _Choice: + def __init__(self, message): + self.message = message + + class _Response: + def __init__(self, message): + self.choices = [_Choice(message)] + + def _fake_completion(**kwargs): + calls.append(kwargs) + if len(calls) == 1: + return _Response( + _Message( + "", + tool_calls=[ + _ToolCall( + "call_wallet", + tool_name, + '{"wallet":"0x1111111111111111111111111111111111111111"}', + ) + ], + ) + ) + return _Response( + _Message( + "I checked the wallet and it is high risk because it appears in a sanctions list." + ) + ) + + monkeypatch.setattr("rooms.agent.litellm.completion", _fake_completion) + + agent = Agent( + AgentConfig( + name="RiskAnalyst", + system_prompt="Screen wallets and explain risk clearly.", + model="ollama/gemma4:e2b", + skills=["finance/wallet_screening"], + ) + ) + + reply = agent.generate_response([{"role": "user", "content": f"Check wallet {bad_wallet}"}]) + assert "high risk" in reply.lower() + assert "{" not in reply and "}" not in reply + assert len(calls) == 2 + tool_msgs = [m for m in calls[1]["messages"] if m.get("role") == "tool"] + assert len(tool_msgs) == 1 + assert '"flagged": true' in tool_msgs[0]["content"].lower() From 97185c334d9f27bb579e55321547b716359c8f8c Mon Sep 17 00:00:00 2001 From: rosspeili Date: Wed, 17 Jun 2026 12:41:21 +0300 Subject: [PATCH 2/2] fix: stabilize skill runtime test monkeypatch targets Patch test monkeypatching to target the imported rooms.agent module object directly so CI import resolution stays deterministic. --- tests/test_skills_runtime.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_skills_runtime.py b/tests/test_skills_runtime.py index 3bfed9b..f8986d7 100644 --- a/tests/test_skills_runtime.py +++ b/tests/test_skills_runtime.py @@ -1,6 +1,7 @@ import types from rooms.agent import Agent +import rooms.agent as agent_module from rooms.config import AgentConfig from rooms.skills_runtime import SkillRuntime @@ -130,7 +131,7 @@ def _fake_completion(**kwargs): ) return _Response(_Message("Readable final answer")) - monkeypatch.setattr("rooms.agent.litellm.completion", _fake_completion) + monkeypatch.setattr(agent_module.litellm, "completion", _fake_completion) agent = Agent( AgentConfig( name="A", @@ -244,7 +245,7 @@ def _fake_completion(**kwargs): ) ) - monkeypatch.setattr("rooms.agent.litellm.completion", _fake_completion) + monkeypatch.setattr(agent_module.litellm, "completion", _fake_completion) agent = Agent( AgentConfig(