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
32 changes: 26 additions & 6 deletions src/praisonai-agents/praisonaiagents/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2257,9 +2257,11 @@ def _add_skill_tools(self):
Uses lazy imports from praisonaiagents.tools to avoid performance impact
when skills are not used.

Honours ``PRAISONAI_DISABLE_SKILL_TOOLS=1`` so hosts that do not want
the subprocess-backed ``run_skill_script`` tool auto-injected can opt
out without touching code.
G-E fix: run_skill_script is now safer by default:
- PRAISONAI_DISABLE_SKILL_TOOLS=1: disables all skill tools (explicit deny wins)
- PRAISONAI_ENABLE_SKILL_TOOLS=1: enables run_skill_script by default
- Any loaded skill with 'run_skill_script' in allowed-tools: enables it
- Otherwise: only read_file is added (safer default)
"""
import os as _os
if _os.environ.get("PRAISONAI_DISABLE_SKILL_TOOLS") in ("1", "true", "True"):
Expand All @@ -2274,7 +2276,7 @@ def _add_skill_tools(self):
elif hasattr(tool, 'name'):
tool_names.add(tool.name)

# Add read_file if not present
# Add read_file if not present (low risk, always enabled)
if 'read_file' not in tool_names:
try:
from ..tools import read_file
Expand All @@ -2283,8 +2285,26 @@ def _add_skill_tools(self):
except ImportError:
logging.warning("Could not import read_file tool for skills")

# Add run_skill_script from skill_tools module
if 'run_skill_script' not in tool_names:
# G-E fix: run_skill_script safer by default
# Only add if explicitly enabled OR any skill declares it in allowed-tools
should_add_script_tool = False

# Check explicit environment enable
if _os.environ.get("PRAISONAI_ENABLE_SKILL_TOOLS") in ("1", "true", "True"):
should_add_script_tool = True
logging.debug("run_skill_script enabled via PRAISONAI_ENABLE_SKILL_TOOLS")

# Check if any loaded skill declares it in allowed-tools
if self._skill_manager and not should_add_script_tool:
for skill in self._skill_manager.skills:
allowed_tools = self._skill_manager.get_allowed_tools(skill.properties.name)
if "run_skill_script" in allowed_tools:
should_add_script_tool = True
logging.debug(f"run_skill_script enabled by skill '{skill.properties.name}' allowed-tools")
break

# Add run_skill_script if conditions are met
if should_add_script_tool and 'run_skill_script' not in tool_names:
try:
from ..tools.skill_tools import create_skill_tools
# Create skill tools with current working directory
Expand Down
44 changes: 26 additions & 18 deletions src/praisonai-agents/praisonaiagents/agent/chat_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1064,30 +1064,38 @@ def _resolve_skill_invocation(self, prompt):
rendered = mgr.invoke(name, raw_args=args)
if rendered is None:
return prompt
# G6: Best-effort pre-approve any tools declared under
# G-A fix: Best-effort pre-approve any tools declared under
# `allowed-tools` in the skill frontmatter. Non-fatal on error.
try:
tool_names = mgr.get_allowed_tools(name)
if tool_names:
from ..approval import get_approval_registry, AutoApproveBackend
from ..approval import get_approval_registry

registry = get_approval_registry()
agent_name = getattr(self, "name", None)
for _tn in tool_names:
try:
registry.set_backend(
AutoApproveBackend(),
agent_name=agent_name,
tool_name=_tn,
)
except TypeError:
# Older registry may not accept tool_name kwarg
registry.set_backend(AutoApproveBackend(), agent_name=agent_name)
except Exception as e: # pragma: no cover - approval is optional
from .._logging import get_logger
logger = get_logger(__name__)
logger.warning(f"Approval system initialization failed for agent {agent_name}: {e}")
# Don't raise - approval is optional, but log the failure for debugging
agent_name = getattr(self, "display_name", getattr(self, "name", None))
if agent_name: # Only approve if we have a stable agent identifier
Comment on lines +1075 to +1076
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use an or fallback for display_name.

Line 1075 only falls back to self.name when display_name is missing; if display_name exists but is None or "", agent_name stays falsey and skips all skill tool pre-approvals.

🐛 Proposed fix
-                agent_name = getattr(self, "display_name", getattr(self, "name", None))
+                agent_name = getattr(self, "display_name", None) or getattr(self, "name", None)
                 if agent_name:  # Only approve if we have a stable agent identifier
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/praisonai-agents/praisonaiagents/agent/chat_mixin.py` around lines 1075 -
1076, The current assignment to agent_name uses getattr(self, "display_name",
getattr(self, "name", None)) which treats an existing but empty or None
display_name as a falsey value and prevents fallback; change the logic in the
chat mixin where agent_name is set so it evaluates display_name or name as
fallbacks (e.g., get display_name first and if it's falsy use self.name),
ensuring the subsequent skill tool pre-approval branch (the if agent_name check)
runs when name is available; update the assignment near agent_name and
references to self.display_name/self.name in that block.

for _tn in tool_names:
try:
registry.auto_approve_tool(_tn, agent_name=agent_name)
except Exception as exc: # pragma: no cover - approval is optional
logging.debug(
"Failed to auto-approve skill tool '%s' for skill '%s' on agent '%s': %s. "
"The skill will continue, but this tool may still require explicit approval.",
_tn,
name,
agent_name,
exc,
exc_info=True,
)
except Exception as exc: # pragma: no cover - approval is optional
logging.debug(
"Failed to resolve allowed tools for skill '%s' on agent '%s': %s. "
"The skill will continue without pre-approving tools.",
name,
getattr(self, "name", None),
exc,
exc_info=True,
)
return rendered

def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
Expand Down
27 changes: 27 additions & 0 deletions src/praisonai-agents/praisonaiagents/approval/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def __init__(self) -> None:
self._required_tools: Set[str] = set()
self._risk_levels: Dict[str, str] = {}

# Per-agent, per-tool auto-approval (G-A fix)
self._agent_tool_auto_approve: Dict[tuple[str, str], bool] = {}

# Context variables (per-coroutine / per-thread)
self._approved_context: contextvars.ContextVar[Set[str]] = contextvars.ContextVar(
"approved_context", default=set()
Expand Down Expand Up @@ -139,6 +142,20 @@ def is_required(self, tool_name: str) -> bool:
def get_risk_level(self, tool_name: str) -> Optional[str]:
return self._risk_levels.get(tool_name)

# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────

def auto_approve_tool(self, tool_name: str, agent_name: str) -> None:
"""Pre-approve a single tool for a specific agent."""
if not agent_name:
raise ValueError("Skill auto-approval requires a stable agent/session scope")
self._agent_tool_auto_approve[(agent_name, tool_name)] = True

def is_auto_approved(self, tool_name: str, agent_name: str) -> bool:
"""Check if a tool is auto-approved for a specific agent."""
if not agent_name:
return False
return self._agent_tool_auto_approve.get((agent_name, tool_name), False)

# ── Context helpers ──────────────────────────────────────────────────

def mark_approved(self, tool_name: str) -> None:
Expand Down Expand Up @@ -189,6 +206,11 @@ def approve_sync(
if self.is_already_approved(tool_name):
return ApprovalDecision(approved=True, reason="Already approved in context")

# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")

# Env auto-approve
if self.is_env_auto_approve():
self.mark_approved(tool_name)
Expand Down Expand Up @@ -237,6 +259,11 @@ async def approve_async(
if self.is_already_approved(tool_name):
return ApprovalDecision(approved=True, reason="Already approved in context")

# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")

if self.is_env_auto_approve():
self.mark_approved(tool_name)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
Expand Down
18 changes: 18 additions & 0 deletions src/praisonai-agents/praisonaiagents/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@
"SkillSourceProtocol",
"SkillInvocationPolicyProtocol",
"SkillMutatorProtocol",
"SkillActivationProtocol",
# Events
"SkillDiscoveredEvent",
"SkillActivatedEvent",
# Budget
"SkillPromptBudget",
]


Expand Down Expand Up @@ -93,6 +99,18 @@ def __getattr__(name: str):
from .protocols import SkillSourceProtocol, SkillInvocationPolicyProtocol, SkillMutatorProtocol
return locals()[name]

if name == "SkillActivationProtocol":
from .activation import SkillActivationProtocol
return SkillActivationProtocol

if name in ("SkillDiscoveredEvent", "SkillActivatedEvent"):
from .events import SkillDiscoveredEvent, SkillActivatedEvent
return locals()[name]

if name == "SkillPromptBudget":
from .budget import SkillPromptBudget
return SkillPromptBudget

if name == "load_skill":
# Fixes G12: praisonai.capabilities.skills.skill_load import target.
# Returns a LoadedSkill (metadata + activated instructions) by name,
Expand Down
29 changes: 29 additions & 0 deletions src/praisonai-agents/praisonaiagents/skills/activation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Agent Skills activation protocol for progressive disclosure."""

from typing import Protocol, Optional


class SkillActivationProtocol(Protocol):
"""Protocol for progressive disclosure skill activation.

This protocol defines the interface for activating skills on demand,
supporting Claude Code-style progressive disclosure where only skill
descriptions are shown in the system prompt initially, with full
bodies loaded when needed.
"""

def activate(self, name: str, arguments: str = "", session_id: Optional[str] = None) -> str:
"""Activate a skill and return its rendered body.

Args:
name: Name of the skill to activate
arguments: Arguments to substitute in the skill body
session_id: Optional session identifier for context

Returns:
Rendered skill body with arguments substituted

Raises:
ValueError: If skill is not found or not user-invocable
"""
...
Comment on lines +6 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Move this protocol into skills/protocols.py.

The interface is correct, but extension-point protocols are expected to live in the module’s protocols.py; keep activation.py for implementations/adapters if needed.

♻️ Proposed structure
-# src/praisonai-agents/praisonaiagents/skills/activation.py
-class SkillActivationProtocol(Protocol):
+// src/praisonai-agents/praisonaiagents/skills/protocols.py
+class SkillActivationProtocol(Protocol):
     ...

Then update lazy exports to import SkillActivationProtocol from .protocols.

As per coding guidelines, src/praisonai-agents/praisonaiagents/**/*.py: “Core SDK (praisonaiagents) must use protocol-driven design with typing.Protocol for all extension points, not heavy implementations.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/praisonai-agents/praisonaiagents/skills/activation.py` around lines 6 -
29, Move the SkillActivationProtocol definition out of activation.py into the
module protocols file (create/update protocols.py) preserving the exact class
name, docstring and the activate(self, name: str, arguments: str = "",
session_id: Optional[str] = None) -> str signature and raises/docs; leave
activation.py to contain only concrete implementations/adapters that import the
protocol. After moving, update any local lazy exports or import sites to import
SkillActivationProtocol from .protocols (not .activation) and ensure
type-checking imports (Optional, Protocol) are preserved where needed.

93 changes: 93 additions & 0 deletions src/praisonai-agents/praisonaiagents/skills/budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Agent Skills prompt budget management.

Controls how many skills and how much content gets included in
the system prompt to prevent unbounded growth with large skill libraries.
"""

import html
from dataclasses import dataclass
from typing import Literal, List
from .models import SkillMetadata

# Maximum description chars before truncation (from prompt.py)
MAX_COMBINED_DESCRIPTION_CHARS = 1536


def _truncate(text: str, limit: int) -> str:
"""Truncate text to limit (same logic as prompt.py)."""
if text is None:
return ""
if len(text) <= limit:
return text
return text[: max(0, limit - 1)].rstrip() + "\u2026"


def _estimate_skill_xml_chars(skill: SkillMetadata) -> int:
"""Estimate the rendered XML character count for a skill."""
name = html.escape(skill.name)
description = html.escape(_truncate(skill.description, MAX_COMBINED_DESCRIPTION_CHARS))
location = html.escape(skill.location or "")

# Exact XML format from format_skill_for_prompt
xml_content = f""" <skill>
<name>{name}</name>
<description>{description}</description>
<location>{location}</location>
</skill>"""

return len(xml_content)


@dataclass(frozen=True)
class SkillPromptBudget:
"""Budget constraints for skills included in system prompts.

Prevents unbounded system prompt growth when agents have access
to large skill libraries.
"""
max_chars: int = 4096
max_skills: int = 50
strategy: Literal["priority", "fifo", "alpha"] = "fifo"


def apply_budget(skills: List[SkillMetadata], budget: SkillPromptBudget) -> tuple[List[SkillMetadata], bool]:
"""Apply budget constraints to a list of skills.

Args:
skills: List of skill metadata to potentially include
budget: Budget constraints to apply

Returns:
Tuple of (filtered_skills, was_truncated)
"""
if not skills:
return skills, False

# Apply ordering strategy first, before selecting the bounded subset
if budget.strategy == "alpha":
ordered_skills = sorted(skills, key=lambda s: s.name)
elif budget.strategy == "fifo":
ordered_skills = skills
else:
# priority strategy would require skill metadata to include priority field;
# for now, treat as fifo
ordered_skills = skills

# Apply skill count limit after ordering
limited_skills = ordered_skills[:budget.max_skills]
skill_count_truncated = len(limited_skills) < len(skills)

total_chars = 0
filtered_skills = []
char_truncated = False

for skill in limited_skills:
skill_chars = _estimate_skill_xml_chars(skill)
if total_chars + skill_chars > budget.max_chars:
char_truncated = True
break
filtered_skills.append(skill)
total_chars += skill_chars
Comment thread
coderabbitai[bot] marked this conversation as resolved.

was_truncated = skill_count_truncated or char_truncated
return filtered_skills, was_truncated
33 changes: 33 additions & 0 deletions src/praisonai-agents/praisonaiagents/skills/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Agent Skills observability events for telemetry and monitoring."""

from dataclasses import dataclass
from typing import Literal


@dataclass
class SkillDiscoveredEvent:
"""Event emitted when a skill is discovered during skill directory scanning.

This event provides visibility into which skills are found and from
what sources during the discovery phase.
"""
agent: str
skill_name: str
source: str # Directory path or source identifier
description_chars: int # Length of skill description


@dataclass
class SkillActivatedEvent:
"""Event emitted when a skill is activated (full body loaded/rendered).

This event tracks skill usage patterns and performance metrics
for skill invocation.
"""
agent: str
skill_name: str
trigger: Literal["slash", "activate_tool", "auto"]
arguments: str
rendered_chars: int
session_id: str | None = None
activation_time_ms: float | None = None
Comment on lines +7 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add correlation metadata and avoid raw user data in telemetry events.

These observability events are hard to join across traces without a correlation/session timestamp, and source/arguments can leak local paths or user-provided sensitive data into telemetry. Prefer structured fields such as correlation_id, timestamp, event_source, source_type/hashed source, and an argument summary or length instead of raw arguments.

🛡️ Possible shape for safer event payloads
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
 from typing import Literal
+
+
+def _utc_now_iso() -> str:
+    return datetime.now(timezone.utc).isoformat()
 
 
 `@dataclass`
 class SkillDiscoveredEvent:
@@
     agent: str
     skill_name: str
-    source: str  # Directory path or source identifier
+    source_type: str  # e.g. "project", "user", "package"
     description_chars: int  # Length of skill description
+    correlation_id: str | None = None
+    event_source: str = "skills"
+    timestamp: str = field(default_factory=_utc_now_iso)
@@
     agent: str
     skill_name: str
     trigger: Literal["slash", "activate_tool", "auto"]
-    arguments: str
+    argument_chars: int = 0
     rendered_chars: int
     session_id: str | None = None
     activation_time_ms: float | None = None
+    correlation_id: str | None = None
+    event_source: str = "skills"
+    timestamp: str = field(default_factory=_utc_now_iso)

Based on learnings, emit events via EventBus for all key operations to enable hooks and observability, with structured event objects containing correlationId, timestamp, and source information.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/praisonai-agents/praisonaiagents/skills/events.py` around lines 7 - 33,
The SkillDiscoveredEvent and SkillActivatedEvent currently expose raw
source/arguments and lack correlation/timestamp metadata; update these
dataclasses (SkillDiscoveredEvent, SkillActivatedEvent) to add correlation_id
(str) and timestamp (float or datetime) fields, replace raw source with
structured fields like event_source (string), source_type (enum or string)
and/or hashed_source (string) and replace arguments with arguments_summary or
arguments_length (int) to avoid leaking paths/user data; ensure existing
session_id and activation_time_ms are preserved, and emit these events via the
project EventBus when discovery/activation occurs so traces can be correlated.

Loading