Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions rooms.settings.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
63 changes: 62 additions & 1 deletion rooms/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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}")
Expand Down
7 changes: 6 additions & 1 deletion rooms/config.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions rooms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)


Expand Down
170 changes: 170 additions & 0 deletions rooms/skills_runtime.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading