mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
3942 lines
134 KiB
Python
3942 lines
134 KiB
Python
"""OpenSpace Skill Protocol surfaces for OpenSpace.
|
|
|
|
This module owns the model-visible protocol:
|
|
- ``skill_listing`` lightweight attachments list names/descriptions only.
|
|
- ``DiscoverSkills`` searches candidates and never returns SKILL.md bodies.
|
|
- ``Skill`` is the only default path that loads full skill content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Mapping, Sequence, TYPE_CHECKING
|
|
|
|
from openspace.grounding.core.permissions.types import (
|
|
AddRulesUpdate,
|
|
DecisionReasonOther,
|
|
DecisionReasonRule,
|
|
PermissionAllow,
|
|
PermissionAsk,
|
|
PermissionDeny,
|
|
PermissionRuleValue,
|
|
ToolPermissionContext,
|
|
normalize_tool_name_for_rule,
|
|
parse_rule_value,
|
|
)
|
|
from openspace.grounding.core.tool.local_tool import LocalTool
|
|
from openspace.grounding.core.types import BackendType, ToolResult, ToolStatus
|
|
from openspace.services.conversation.attachments import create_attachment_message
|
|
from openspace.services.conversation.content_blocks import extract_text_from_content
|
|
from openspace.services.tooling.context import (
|
|
SkillInvocationRecord,
|
|
SkillInvocationScope,
|
|
ToolUseContext,
|
|
)
|
|
from openspace.utils.logging import Logger
|
|
|
|
if TYPE_CHECKING:
|
|
from openspace.skill_engine.registry import SkillMeta, SkillRegistry
|
|
from openspace.skill_engine.store import SkillStore
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
|
|
SKILL_TOOL_NAME = "Skill"
|
|
DISCOVER_SKILLS_TOOL_NAME = "DiscoverSkills"
|
|
SKILL_BUDGET_CONTEXT_PERCENT = 0.01
|
|
CHARS_PER_TOKEN = 4
|
|
DEFAULT_CHAR_BUDGET = 8_000
|
|
MAX_LISTING_DESC_CHARS = 250
|
|
FILTERED_LISTING_MAX = 30
|
|
LIST_ALL_SKILLS_MAX = 30
|
|
LARGE_SKILL_LIST_THRESHOLD = 100
|
|
DIRECT_MATCH_SCORE = 100.0
|
|
PREFIX_MATCH_SCORE = 80.0
|
|
KEYWORD_HIGH_CONFIDENCE_MIN_SCORE = 6.0
|
|
KEYWORD_HIGH_CONFIDENCE_RATIO = 1.5
|
|
HYBRID_HIGH_CONFIDENCE_MIN_SCORE = 0.25
|
|
HYBRID_HIGH_CONFIDENCE_MIN_MARGIN = 0.05
|
|
HYBRID_HIGH_CONFIDENCE_RATIO = 1.15
|
|
MIN_DESC_LENGTH = 20
|
|
PROMPT_SHELL_BLOCK_RE = re.compile(r"```!\s*\n?([\s\S]*?)\n?```")
|
|
PROMPT_SHELL_INLINE_RE = re.compile(r"(^|\s)!`([^`]+)`", re.MULTILINE)
|
|
DEFAULT_PROMPT_HOOK_TIMEOUT_SECONDS = 30.0
|
|
|
|
|
|
def _disabled_skill_ids(store: Any | None) -> set[str]:
|
|
if store is None:
|
|
return set()
|
|
try:
|
|
return {
|
|
str(row.get("skill_id") or "")
|
|
for row in store.get_summary(active_only=True)
|
|
if not bool(row.get("enabled", True))
|
|
}
|
|
except Exception:
|
|
logger.debug("Skill enabled-state lookup failed", exc_info=True)
|
|
return set()
|
|
|
|
|
|
def tool_matches_name(tool: Any, name: str) -> bool:
|
|
if getattr(tool, "name", None) == name:
|
|
return True
|
|
return name in set(getattr(tool, "aliases", []) or ())
|
|
|
|
|
|
def has_skill_tool(tools: Sequence[Any]) -> bool:
|
|
return any(tool_matches_name(tool, SKILL_TOOL_NAME) for tool in tools)
|
|
|
|
|
|
def get_char_budget(
|
|
context_window_tokens: int | None = None,
|
|
*,
|
|
context_percent: float = SKILL_BUDGET_CONTEXT_PERCENT,
|
|
) -> int:
|
|
if context_window_tokens:
|
|
return int(context_window_tokens * CHARS_PER_TOKEN * context_percent)
|
|
return DEFAULT_CHAR_BUDGET
|
|
|
|
|
|
def _entry_description(
|
|
skill: "SkillMeta",
|
|
*,
|
|
max_description_chars: int = MAX_LISTING_DESC_CHARS,
|
|
) -> str:
|
|
description = skill.description or skill.name
|
|
if skill.when_to_use:
|
|
description = f"{description} - {skill.when_to_use}"
|
|
if len(description) > max_description_chars:
|
|
return description[: max_description_chars - 1] + "..."
|
|
return description
|
|
|
|
|
|
def _format_entry(
|
|
skill: "SkillMeta",
|
|
*,
|
|
max_desc_len: int | None = None,
|
|
max_description_chars: int = MAX_LISTING_DESC_CHARS,
|
|
) -> str:
|
|
description = _entry_description(
|
|
skill,
|
|
max_description_chars=max_description_chars,
|
|
)
|
|
if max_desc_len is not None and len(description) > max_desc_len:
|
|
if max_desc_len < 4:
|
|
return f"- {skill.name}"
|
|
description = description[: max_desc_len - 3] + "..."
|
|
return f"- {skill.name}: {description}"
|
|
|
|
|
|
def format_skills_within_budget(
|
|
skills: Sequence["SkillMeta"],
|
|
*,
|
|
context_window_tokens: int | None = None,
|
|
context_percent: float = SKILL_BUDGET_CONTEXT_PERCENT,
|
|
max_description_chars: int = MAX_LISTING_DESC_CHARS,
|
|
) -> str:
|
|
if not skills:
|
|
return ""
|
|
|
|
budget = get_char_budget(context_window_tokens, context_percent=context_percent)
|
|
full_entries = [
|
|
_format_entry(skill, max_description_chars=max_description_chars)
|
|
for skill in skills
|
|
]
|
|
full_total = sum(len(entry) for entry in full_entries) + max(0, len(full_entries) - 1)
|
|
if full_total <= budget:
|
|
return "\n".join(full_entries)
|
|
|
|
bundled_indices = {
|
|
idx
|
|
for idx, skill in enumerate(skills)
|
|
if skill.loaded_from == "bundled" or skill.source == "bundled"
|
|
}
|
|
rest = [skill for idx, skill in enumerate(skills) if idx not in bundled_indices]
|
|
bundled_chars = sum(len(full_entries[idx]) + 1 for idx in bundled_indices)
|
|
if not rest:
|
|
return "\n".join(full_entries)
|
|
|
|
remaining_budget = budget - bundled_chars
|
|
rest_name_overhead = sum(len(skill.name) + 4 for skill in rest) + max(0, len(rest) - 1)
|
|
max_desc_len = (remaining_budget - rest_name_overhead) // max(1, len(rest))
|
|
if max_desc_len < MIN_DESC_LENGTH:
|
|
return "\n".join(
|
|
full_entries[idx] if idx in bundled_indices else f"- {skill.name}"
|
|
for idx, skill in enumerate(skills)
|
|
)
|
|
|
|
return "\n".join(
|
|
full_entries[idx]
|
|
if idx in bundled_indices
|
|
else _format_entry(
|
|
skill,
|
|
max_desc_len=max_desc_len,
|
|
max_description_chars=max_description_chars,
|
|
)
|
|
for idx, skill in enumerate(skills)
|
|
)
|
|
|
|
|
|
def filter_to_bundled_and_mcp(skills: Sequence["SkillMeta"]) -> list["SkillMeta"]:
|
|
filtered = [
|
|
skill
|
|
for skill in skills
|
|
if skill.loaded_from in {"bundled", "mcp"} or skill.source in {"bundled", "mcp"}
|
|
]
|
|
if len(filtered) > FILTERED_LISTING_MAX:
|
|
return [
|
|
skill
|
|
for skill in filtered
|
|
if skill.loaded_from == "bundled" or skill.source == "bundled"
|
|
]
|
|
return filtered
|
|
|
|
|
|
def _prioritize_large_skill_listing(
|
|
skills: Sequence["SkillMeta"],
|
|
context: ToolUseContext,
|
|
) -> list["SkillMeta"]:
|
|
"""Keep the initial listing useful when the local skill universe is huge."""
|
|
|
|
dynamic_names = set(context.discovered_skill_names)
|
|
activated_names = set(context.path_activated_skill_names)
|
|
priority: list["SkillMeta"] = []
|
|
remainder: list["SkillMeta"] = []
|
|
for skill in skills:
|
|
is_priority = (
|
|
skill.loaded_from in {"bundled", "mcp"}
|
|
or skill.source in {"bundled", "mcp"}
|
|
or skill.name in dynamic_names
|
|
or skill.name in activated_names
|
|
)
|
|
if is_priority:
|
|
priority.append(skill)
|
|
else:
|
|
remainder.append(skill)
|
|
|
|
if len(priority) >= FILTERED_LISTING_MAX:
|
|
return priority[:FILTERED_LISTING_MAX]
|
|
return priority + remainder[: FILTERED_LISTING_MAX - len(priority)]
|
|
|
|
|
|
def _is_forced_skill_select_query(query: str) -> bool:
|
|
return str(query or "").strip().lower().startswith("select:")
|
|
|
|
|
|
def _context_window_tokens_for_listing(context: ToolUseContext) -> int | None:
|
|
try:
|
|
from openspace.services.conversation.compact import get_effective_context_window_size
|
|
|
|
model = str(getattr(context, "model", "") or "")
|
|
return int(get_effective_context_window_size(model)) if model else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class SkillListingService:
|
|
"""Build OpenSpace per-agent skill_listing deltas."""
|
|
|
|
def __init__(
|
|
self,
|
|
registry: "SkillRegistry",
|
|
*,
|
|
discovery_enabled: bool = True,
|
|
store: "SkillStore | None" = None,
|
|
listing_budget_context_percent: float = SKILL_BUDGET_CONTEXT_PERCENT,
|
|
listing_max_description_chars: int = MAX_LISTING_DESC_CHARS,
|
|
) -> None:
|
|
self._registry = registry
|
|
self._discovery_enabled = discovery_enabled
|
|
self._store = store
|
|
self._listing_budget_context_percent = max(
|
|
0.0,
|
|
float(listing_budget_context_percent),
|
|
)
|
|
self._listing_max_description_chars = max(
|
|
MIN_DESC_LENGTH,
|
|
int(listing_max_description_chars),
|
|
)
|
|
|
|
def get_listing_delta(
|
|
self,
|
|
context: ToolUseContext,
|
|
tools: Sequence[Any],
|
|
) -> list[dict[str, Any]]:
|
|
if not has_skill_tool(tools):
|
|
return []
|
|
|
|
disabled = _disabled_skill_ids(self._store)
|
|
skills = [
|
|
skill
|
|
for skill in self._registry.list_skills()
|
|
if (
|
|
not skill.disable_model_invocation
|
|
and _skill_visible_to_context(skill, context)
|
|
and skill.skill_id not in disabled
|
|
)
|
|
]
|
|
total_visible_skills = len(skills)
|
|
if self._discovery_enabled and len(skills) > LARGE_SKILL_LIST_THRESHOLD:
|
|
skills = _prioritize_large_skill_listing(skills, context)
|
|
if not skills:
|
|
return []
|
|
|
|
agent_key = context.agent_id or "primary"
|
|
sent = context.sent_skill_names_by_agent.setdefault(agent_key, set())
|
|
|
|
if context.skill_listing_suppressed_once:
|
|
context.skill_listing_suppressed_once = False
|
|
if not any(skill.name not in sent for skill in skills):
|
|
return []
|
|
|
|
new_skills = [skill for skill in skills if skill.name not in sent]
|
|
if not new_skills:
|
|
return []
|
|
|
|
is_initial = not sent
|
|
sent.update(skill.name for skill in new_skills)
|
|
content = format_skills_within_budget(
|
|
new_skills,
|
|
context_window_tokens=_context_window_tokens_for_listing(context),
|
|
context_percent=self._listing_budget_context_percent,
|
|
max_description_chars=self._listing_max_description_chars,
|
|
)
|
|
omitted_count = max(0, total_visible_skills - len(skills))
|
|
if omitted_count:
|
|
content = (
|
|
f"{content}\n\n"
|
|
f"{omitted_count} additional skills are available. "
|
|
"Call DiscoverSkills with a short workflow query, or use "
|
|
"select:<skill_name> when you already know the exact skill."
|
|
)
|
|
if self._store is not None:
|
|
for skill in new_skills:
|
|
try:
|
|
self._store.record_skill_event_now(
|
|
skill.skill_id,
|
|
"listed",
|
|
source="skill_listing",
|
|
**_skill_event_context_kwargs(context, skill),
|
|
metadata={"is_initial": is_initial},
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill listed event record failed", exc_info=True)
|
|
return [
|
|
{
|
|
"type": "skill_listing",
|
|
"content": content,
|
|
"skillCount": len(new_skills),
|
|
"totalSkillCount": total_visible_skills,
|
|
"omittedSkillCount": omitted_count,
|
|
"skillNames": [skill.name for skill in new_skills],
|
|
"skillIds": [skill.skill_id for skill in new_skills],
|
|
"isInitial": is_initial,
|
|
}
|
|
]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SkillDiscoveryHit:
|
|
name: str
|
|
description: str
|
|
skill_id: str
|
|
source: str
|
|
score: float
|
|
|
|
|
|
class SkillDiscoveryService:
|
|
"""OpenSpace-backed discovery with legacy-compatible output semantics."""
|
|
|
|
def __init__(
|
|
self,
|
|
registry: "SkillRegistry",
|
|
*,
|
|
store: "SkillStore | None" = None,
|
|
llm_client: Any | None = None,
|
|
) -> None:
|
|
self._registry = registry
|
|
self._store = store
|
|
self._llm_client = llm_client
|
|
|
|
@property
|
|
def store(self) -> "SkillStore | None":
|
|
return self._store
|
|
|
|
def _direct_hits(
|
|
self,
|
|
query: str,
|
|
*,
|
|
context: ToolUseContext | None,
|
|
max_results: int,
|
|
) -> list[SkillDiscoveryHit]:
|
|
normalized = str(query or "").strip()
|
|
if not normalized:
|
|
return []
|
|
|
|
selection_query = normalized
|
|
forced_select = False
|
|
if normalized.lower().startswith("select:"):
|
|
forced_select = True
|
|
selection_query = normalized.split(":", 1)[1].strip()
|
|
selection_query = _normalize_skill_name(selection_query)
|
|
if not selection_query:
|
|
return []
|
|
|
|
candidates = self._candidate_skills(context, include_seen=True)
|
|
exact = [
|
|
skill for skill in candidates
|
|
if _skill_matches_exact(skill, selection_query)
|
|
]
|
|
if exact:
|
|
return [
|
|
_hit_for_skill(skill, DIRECT_MATCH_SCORE)
|
|
for skill in exact[: max(1, max_results)]
|
|
]
|
|
|
|
prefix_matches = [
|
|
skill for skill in candidates
|
|
if _skill_matches_prefix(skill, selection_query)
|
|
]
|
|
if len(prefix_matches) == 1:
|
|
return [_hit_for_skill(prefix_matches[0], PREFIX_MATCH_SCORE)]
|
|
|
|
# `select:` is an exact instruction. If it did not resolve, do not
|
|
# reinterpret it as a broad semantic query.
|
|
if forced_select:
|
|
return []
|
|
|
|
return []
|
|
|
|
@staticmethod
|
|
def _high_confidence_keyword_hits(
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
) -> bool:
|
|
if not hits:
|
|
return False
|
|
first = hits[0].score
|
|
if first < KEYWORD_HIGH_CONFIDENCE_MIN_SCORE:
|
|
return False
|
|
if len(hits) == 1:
|
|
return True
|
|
second = max(hits[1].score, 0.0001)
|
|
return first / second >= KEYWORD_HIGH_CONFIDENCE_RATIO
|
|
|
|
@staticmethod
|
|
def _high_confidence_hybrid_hits(
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
) -> bool:
|
|
if not hits:
|
|
return False
|
|
first = hits[0].score
|
|
if first < HYBRID_HIGH_CONFIDENCE_MIN_SCORE:
|
|
return False
|
|
if len(hits) == 1:
|
|
return True
|
|
second = hits[1].score
|
|
if second <= 0:
|
|
return True
|
|
return (
|
|
first - second >= HYBRID_HIGH_CONFIDENCE_MIN_MARGIN
|
|
and first / second >= HYBRID_HIGH_CONFIDENCE_RATIO
|
|
)
|
|
|
|
def _hybrid_search(
|
|
self,
|
|
query: str,
|
|
candidates: Sequence["SkillMeta"],
|
|
*,
|
|
max_results: int,
|
|
fallback_hits: Sequence[SkillDiscoveryHit] = (),
|
|
) -> list[SkillDiscoveryHit]:
|
|
try:
|
|
from openspace.skill_engine.skill_ranker import SkillCandidate
|
|
|
|
rank_candidates: list[SkillCandidate] = []
|
|
for skill in candidates:
|
|
body = self._registry.load_skill_content(skill.skill_id) or ""
|
|
rank_candidates.append(
|
|
SkillCandidate(
|
|
skill_id=skill.skill_id,
|
|
name=skill.name,
|
|
description=skill.description,
|
|
body=body,
|
|
)
|
|
)
|
|
|
|
ranked = self._registry.ranker.hybrid_rank(
|
|
query,
|
|
rank_candidates,
|
|
top_k=max(1, max_results),
|
|
)
|
|
by_id = {skill.skill_id: skill for skill in candidates}
|
|
hits: list[SkillDiscoveryHit] = []
|
|
for item in ranked:
|
|
skill = by_id.get(item.skill_id)
|
|
if skill is None:
|
|
continue
|
|
score = item.score or item.vector_score or item.bm25_score
|
|
hits.append(_hit_for_skill(skill, float(score)))
|
|
if hits and all(hit.score <= 0 for hit in hits):
|
|
return list(fallback_hits)
|
|
return hits
|
|
except Exception:
|
|
logger.debug("Skill discovery hybrid ranking failed", exc_info=True)
|
|
return list(fallback_hits)
|
|
|
|
def search(
|
|
self,
|
|
query: str,
|
|
*,
|
|
context: ToolUseContext | None = None,
|
|
max_results: int = 5,
|
|
) -> list[SkillDiscoveryHit]:
|
|
normalized = str(query or "").strip()
|
|
if not normalized:
|
|
return []
|
|
|
|
direct_hits = self._direct_hits(
|
|
normalized,
|
|
context=context,
|
|
max_results=max_results,
|
|
)
|
|
if direct_hits:
|
|
return direct_hits
|
|
if _is_forced_skill_select_query(normalized):
|
|
return []
|
|
|
|
candidates = self._candidate_skills(context)
|
|
if not candidates:
|
|
return []
|
|
|
|
keyword_hits = self._token_overlap_search(
|
|
normalized,
|
|
context=context,
|
|
max_results=max_results,
|
|
candidates=candidates,
|
|
)
|
|
if self._high_confidence_keyword_hits(keyword_hits):
|
|
return keyword_hits
|
|
|
|
return self._hybrid_search(
|
|
normalized,
|
|
candidates,
|
|
max_results=max_results,
|
|
fallback_hits=keyword_hits,
|
|
)
|
|
|
|
async def asearch(
|
|
self,
|
|
query: str,
|
|
*,
|
|
context: ToolUseContext | None = None,
|
|
max_results: int = 5,
|
|
model: str | None = None,
|
|
) -> list[SkillDiscoveryHit]:
|
|
"""Run discovery through OpenSpace's legacy selector when an LLM exists."""
|
|
|
|
normalized = str(query or "").strip()
|
|
if not normalized:
|
|
return []
|
|
|
|
direct_hits = self._direct_hits(
|
|
normalized,
|
|
context=context,
|
|
max_results=max_results,
|
|
)
|
|
if direct_hits:
|
|
return direct_hits
|
|
if _is_forced_skill_select_query(normalized):
|
|
return []
|
|
|
|
candidates = self._candidate_skills(context)
|
|
if not candidates:
|
|
return []
|
|
|
|
keyword_hits = self._token_overlap_search(
|
|
normalized,
|
|
context=context,
|
|
max_results=max_results,
|
|
candidates=candidates,
|
|
)
|
|
if self._high_confidence_keyword_hits(keyword_hits):
|
|
return keyword_hits
|
|
|
|
hybrid_hits = self._hybrid_search(
|
|
normalized,
|
|
candidates,
|
|
max_results=max_results,
|
|
fallback_hits=keyword_hits,
|
|
)
|
|
if self._high_confidence_hybrid_hits(hybrid_hits):
|
|
return hybrid_hits
|
|
|
|
if self._llm_client is None:
|
|
return hybrid_hits
|
|
|
|
try:
|
|
selected, record = await self._registry.select_skills_with_llm(
|
|
normalized,
|
|
llm_client=self._llm_client,
|
|
max_skills=max(1, max_results),
|
|
model=model,
|
|
skill_quality=self._quality_by_skill_id(),
|
|
candidate_skills=candidates,
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill discovery LLM selector failed", exc_info=True)
|
|
return hybrid_hits
|
|
|
|
if record and record.get("method") == "llm_failed":
|
|
return hybrid_hits
|
|
|
|
hits: list[SkillDiscoveryHit] = []
|
|
for index, skill in enumerate(selected[: max(1, max_results)]):
|
|
hits.append(
|
|
SkillDiscoveryHit(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
skill_id=skill.skill_id,
|
|
source=skill.source,
|
|
score=float(max_results - index),
|
|
)
|
|
)
|
|
return hits or hybrid_hits
|
|
|
|
def _candidate_skills(
|
|
self,
|
|
context: ToolUseContext | None,
|
|
*,
|
|
include_seen: bool = False,
|
|
) -> list["SkillMeta"]:
|
|
visible = set()
|
|
invoked = set()
|
|
if context is not None:
|
|
agent_key = context.agent_id or "primary"
|
|
visible.update(context.sent_skill_names_by_agent.get(agent_key, set()))
|
|
visible.update(context.discovered_skill_names)
|
|
for records in context.invoked_skills_by_agent.values():
|
|
invoked.update(record.name for record in records)
|
|
|
|
candidates: list["SkillMeta"] = []
|
|
disabled = _disabled_skill_ids(self._store)
|
|
for skill in self._registry.list_skills():
|
|
if skill.disable_model_invocation:
|
|
continue
|
|
if skill.skill_id in disabled:
|
|
continue
|
|
if not _skill_visible_to_context(skill, context):
|
|
continue
|
|
if not include_seen and (skill.name in visible or skill.name in invoked):
|
|
continue
|
|
candidates.append(skill)
|
|
return candidates
|
|
|
|
def _token_overlap_search(
|
|
self,
|
|
query: str,
|
|
*,
|
|
context: ToolUseContext | None,
|
|
max_results: int,
|
|
candidates: Sequence["SkillMeta"] | None = None,
|
|
) -> list[SkillDiscoveryHit]:
|
|
quality = self._quality_by_skill_id()
|
|
tokens = _tokenize(query)
|
|
hits: list[SkillDiscoveryHit] = []
|
|
search_candidates = (
|
|
list(candidates)
|
|
if candidates is not None
|
|
else self._candidate_skills(context)
|
|
)
|
|
metadata_only = bool(
|
|
getattr(context, "skill_metadata_only_discovery", False)
|
|
if context is not None
|
|
else False
|
|
)
|
|
for skill in search_candidates:
|
|
body = (
|
|
""
|
|
if metadata_only
|
|
else self._registry.load_skill_content(skill.skill_id) or ""
|
|
)
|
|
score = _score_skill(tokens, skill.name, skill.description, skill.when_to_use, body)
|
|
if score <= 0:
|
|
continue
|
|
q = quality.get(skill.skill_id)
|
|
if q:
|
|
score += min(float(q.get("total_completions", 0)), 5.0) * 0.05
|
|
score -= min(float(q.get("total_fallbacks", 0)), 5.0) * 0.05
|
|
hits.append(
|
|
SkillDiscoveryHit(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
skill_id=skill.skill_id,
|
|
source=skill.source,
|
|
score=score,
|
|
)
|
|
)
|
|
|
|
hits.sort(key=lambda item: item.score, reverse=True)
|
|
return hits[: max(1, max_results)]
|
|
|
|
def build_attachment(
|
|
self,
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
*,
|
|
signal: Mapping[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"type": "skill_discovery",
|
|
"skills": [
|
|
{
|
|
"name": hit.name,
|
|
"description": hit.description,
|
|
"skill_id": hit.skill_id,
|
|
"source": hit.source,
|
|
}
|
|
for hit in hits
|
|
],
|
|
"signal": dict(signal or {}),
|
|
"source": "openspace",
|
|
}
|
|
|
|
def _quality_by_skill_id(self) -> dict[str, dict[str, Any]]:
|
|
if self._store is None:
|
|
return {}
|
|
try:
|
|
rows = self._store.get_summary(active_only=True)
|
|
return {
|
|
row["skill_id"]: {
|
|
"total_selections": row.get("total_selections", 0),
|
|
"total_applied": row.get("total_applied", 0),
|
|
"total_completions": row.get("total_completions", 0),
|
|
"total_fallbacks": row.get("total_fallbacks", 0),
|
|
"enabled": row.get("enabled", True),
|
|
"trust_state": row.get("trust_state", "trusted"),
|
|
"trust_successes": row.get("trust_successes", 0),
|
|
"trust_failures": row.get("trust_failures", 0),
|
|
}
|
|
for row in rows
|
|
}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
class SkillTool(LocalTool):
|
|
"""Load a single SKILL.md into the conversation."""
|
|
|
|
_name = SKILL_TOOL_NAME
|
|
_description = "Execute a skill within the main conversation"
|
|
backend_type = BackendType.META
|
|
_is_read_only = True
|
|
_is_concurrency_safe = False
|
|
always_load = True
|
|
max_result_size_chars = 100_000
|
|
search_hint = "invoke a slash-command skill"
|
|
parameter_descriptions = {
|
|
"skill": 'The skill name, such as "commit", "review-pr", or "pdf".',
|
|
"args": "Optional arguments for the skill.",
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
registry: "SkillRegistry",
|
|
*,
|
|
skill_store: "SkillStore | None" = None,
|
|
) -> None:
|
|
self._registry = registry
|
|
self._skill_store = skill_store
|
|
self._current_context: ToolUseContext | None = None
|
|
self._current_tool_use_id: str = ""
|
|
super().__init__(verbose=False, handle_errors=False)
|
|
|
|
def set_context(self, context: ToolUseContext) -> None:
|
|
self._current_context = context
|
|
|
|
def set_current_tool_use(self, *, tool_use_id: str, tool_name: str = "") -> None:
|
|
self._current_tool_use_id = str(tool_use_id or "")
|
|
|
|
def get_prompt(self, context: Any = None) -> str:
|
|
return (
|
|
"Execute a skill within the main conversation.\n\n"
|
|
"When users ask you to perform tasks, check whether any available "
|
|
"skill matches. Skills provide specialized capabilities and domain "
|
|
"knowledge.\n\n"
|
|
"When a user references a slash command like `/commit`, call this "
|
|
"tool with `skill: \"commit\"`.\n\n"
|
|
"Important:\n"
|
|
"- Available skills are listed in system-reminder messages.\n"
|
|
"- When a skill matches the user's request, call this Skill tool "
|
|
"before responding about the task.\n"
|
|
"- Never mention a skill without actually calling this tool.\n"
|
|
"- If a <command-name> tag for the skill is already visible in the "
|
|
"current turn, follow those instructions instead of calling again."
|
|
)
|
|
|
|
async def validate_input(
|
|
self,
|
|
input: dict[str, Any],
|
|
context: Any = None,
|
|
) -> str | None:
|
|
raw_skill = str(input.get("skill") or "")
|
|
skill_name = _normalize_skill_name(raw_skill)
|
|
if not skill_name:
|
|
return f"Invalid skill format: {raw_skill}"
|
|
skill = self._registry.resolve_skill_for_model(skill_name)
|
|
if skill is None:
|
|
return f"Unknown skill: {skill_name}"
|
|
if skill.skill_id in _disabled_skill_ids(self._skill_store):
|
|
return f"Skill {skill_name} is disabled"
|
|
if not _skill_visible_to_context(skill, self._current_context or context):
|
|
return f"Skill {skill_name} is not available until a matching path is touched"
|
|
if skill.disable_model_invocation:
|
|
return (
|
|
f"Skill {skill_name} cannot be used with {SKILL_TOOL_NAME} "
|
|
"tool due to disable-model-invocation"
|
|
)
|
|
return None
|
|
|
|
async def check_permissions(
|
|
self,
|
|
input: dict[str, Any],
|
|
context: Any = None,
|
|
):
|
|
raw_skill = str(input.get("skill") or "")
|
|
skill_name = _normalize_skill_name(raw_skill)
|
|
meta = self._registry.resolve_skill_for_model(skill_name)
|
|
event_context = context or self._current_context
|
|
if meta is None:
|
|
return PermissionDeny(
|
|
message=f"Unknown skill: {skill_name}",
|
|
decision_reason=DecisionReasonOther(reason="unknown skill"),
|
|
)
|
|
if meta.skill_id in _disabled_skill_ids(self._skill_store):
|
|
return PermissionDeny(
|
|
message=f"Skill {skill_name} is disabled",
|
|
decision_reason=DecisionReasonOther(reason="skill disabled"),
|
|
)
|
|
if not _skill_visible_to_context(meta, event_context):
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_denied",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={"reason": "conditional skill not active"},
|
|
)
|
|
return PermissionDeny(
|
|
message=f"Skill {skill_name} is not available until a matching path is touched",
|
|
decision_reason=DecisionReasonOther(reason="conditional skill not active"),
|
|
)
|
|
if meta.disable_model_invocation:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_denied",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={"reason": "disable-model-invocation"},
|
|
)
|
|
return PermissionDeny(
|
|
message=(
|
|
f"Skill {skill_name} cannot be used with {SKILL_TOOL_NAME} "
|
|
"tool due to disable-model-invocation"
|
|
),
|
|
decision_reason=DecisionReasonOther(reason="disable-model-invocation"),
|
|
)
|
|
|
|
perm_ctx = getattr(event_context, "permission_context", None)
|
|
if perm_ctx is not None:
|
|
deny_rule = _matching_skill_rule(perm_ctx, meta.name, "deny")
|
|
if deny_rule is not None:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_denied",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={
|
|
"reason": "permission rule deny",
|
|
"rule": str(deny_rule),
|
|
},
|
|
)
|
|
return PermissionDeny(
|
|
message="Skill execution blocked by permission rules",
|
|
decision_reason=DecisionReasonRule(rule=deny_rule),
|
|
)
|
|
allow_rule = _matching_skill_rule(perm_ctx, meta.name, "allow")
|
|
if allow_rule is not None:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_granted",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={
|
|
"reason": "permission rule allow",
|
|
"rule": str(allow_rule),
|
|
},
|
|
)
|
|
return PermissionAllow(
|
|
updated_input=input,
|
|
decision_reason=DecisionReasonRule(rule=allow_rule),
|
|
)
|
|
ask_rule = _matching_skill_rule(perm_ctx, meta.name, "ask")
|
|
if ask_rule is not None:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_requested",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={
|
|
"reason": "permission rule ask",
|
|
"rule": str(ask_rule),
|
|
},
|
|
)
|
|
return PermissionAsk(
|
|
message=_skill_permission_ask_message(meta),
|
|
updated_input=input,
|
|
suggestions=(
|
|
AddRulesUpdate(
|
|
destination="localSettings",
|
|
rules=(PermissionRuleValue(SKILL_TOOL_NAME, meta.name),),
|
|
behavior="allow",
|
|
),
|
|
AddRulesUpdate(
|
|
destination="localSettings",
|
|
rules=(PermissionRuleValue(SKILL_TOOL_NAME, f"{meta.name}:*"),),
|
|
behavior="allow",
|
|
),
|
|
),
|
|
decision_reason=DecisionReasonRule(rule=ask_rule),
|
|
)
|
|
|
|
if _skill_has_only_safe_properties(meta):
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"permission_granted",
|
|
source="skill_tool_permission",
|
|
context=event_context,
|
|
metadata={"reason": "safe skill properties"},
|
|
)
|
|
return PermissionAllow(updated_input=input)
|
|
|
|
return PermissionAsk(
|
|
message=_skill_permission_ask_message(meta),
|
|
updated_input=input,
|
|
suggestions=(
|
|
AddRulesUpdate(
|
|
destination="localSettings",
|
|
rules=(PermissionRuleValue(SKILL_TOOL_NAME, meta.name),),
|
|
behavior="allow",
|
|
),
|
|
AddRulesUpdate(
|
|
destination="localSettings",
|
|
rules=(PermissionRuleValue(SKILL_TOOL_NAME, f"{meta.name}:*"),),
|
|
behavior="allow",
|
|
),
|
|
),
|
|
decision_reason=DecisionReasonOther(reason="skill has unsafe runtime fields"),
|
|
)
|
|
|
|
async def _arun(self, skill: str, args: str | None = None) -> ToolResult:
|
|
skill_name = _normalize_skill_name(skill)
|
|
meta = self._registry.resolve_skill_for_model(skill_name)
|
|
context = self._current_context
|
|
if meta is None:
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content=f"Unknown skill: {skill_name}",
|
|
error=f"Unknown skill: {skill_name}",
|
|
)
|
|
if not _skill_visible_to_context(meta, context):
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"load_failed",
|
|
source="skill_tool",
|
|
context=context,
|
|
metadata={"reason": "conditional skill not active"},
|
|
)
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content=f"Skill {skill_name} is not available until a matching path is touched.",
|
|
error="conditional skill not active",
|
|
)
|
|
if meta.disable_model_invocation:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"load_failed",
|
|
source="skill_tool",
|
|
context=context,
|
|
metadata={"reason": "disable-model-invocation"},
|
|
)
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content=(
|
|
f"Skill {skill_name} cannot be used with {SKILL_TOOL_NAME} "
|
|
"tool due to disable-model-invocation"
|
|
),
|
|
error="disable-model-invocation",
|
|
)
|
|
|
|
content = self._registry.build_skill_invocation_context(meta, args=args or "")
|
|
if not content:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"load_failed",
|
|
source="skill_tool",
|
|
context=context,
|
|
metadata={"reason": "skill content unavailable"},
|
|
)
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content=f"Skill {meta.name} could not be loaded.",
|
|
error="skill content unavailable",
|
|
)
|
|
try:
|
|
content = await _execute_skill_prompt_shell_expansions(
|
|
content,
|
|
meta=meta,
|
|
context=context,
|
|
)
|
|
except Exception as exc:
|
|
await _record_skill_event_for_context(
|
|
self._skill_store,
|
|
meta,
|
|
"load_failed",
|
|
source="skill_tool",
|
|
context=context,
|
|
metadata={
|
|
"reason": "skill shell expansion error",
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content=f"Skill {meta.name} could not be loaded: {exc}",
|
|
error=str(exc),
|
|
metadata={
|
|
"tool": self.name,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"error_type": "skill_shell_expansion_error",
|
|
},
|
|
)
|
|
|
|
invocation_args = args or ""
|
|
skill_scope_id = f"skill_{uuid.uuid4().hex[:12]}"
|
|
invocation_tool_use_id = self._current_tool_use_id
|
|
invocation_tool_event_ref_id = _tool_event_ref_id_for_context(
|
|
context,
|
|
invocation_tool_use_id,
|
|
)
|
|
skill_event_ref_id: str | None = None
|
|
if context is not None:
|
|
context.record_invoked_skill(
|
|
SkillInvocationRecord(
|
|
skill_id=meta.skill_id,
|
|
name=meta.name,
|
|
path=str(meta.path),
|
|
content=content,
|
|
args=invocation_args,
|
|
allowed_tools=list(meta.allowed_tools),
|
|
model=meta.model,
|
|
effort=meta.effort,
|
|
execution_context=meta.execution_context or "inline",
|
|
)
|
|
)
|
|
context.mark_skills_discovered([meta.name])
|
|
if self._skill_store is not None:
|
|
try:
|
|
await self._skill_store.sync_from_registry([meta])
|
|
skill_event_metadata = {
|
|
"skill_name": meta.name,
|
|
"execution_context": meta.execution_context or "inline",
|
|
"skill_scope_id": skill_scope_id,
|
|
"skill_invocation_scope_id": skill_scope_id,
|
|
"allowed_tools": list(meta.allowed_tools),
|
|
}
|
|
if context is not None and getattr(context, "session_id", None):
|
|
skill_event_metadata["session_id"] = str(context.session_id)
|
|
if invocation_tool_use_id:
|
|
skill_event_metadata["invocation_tool_use_id"] = invocation_tool_use_id
|
|
if invocation_tool_event_ref_id:
|
|
skill_event_metadata["raw_backrefs"] = [invocation_tool_event_ref_id]
|
|
skill_event_metadata["invocation_tool_event_ref_id"] = (
|
|
invocation_tool_event_ref_id
|
|
)
|
|
event_row = await self._skill_store.record_skill_event(
|
|
meta.skill_id,
|
|
"invoked",
|
|
source="skill_tool",
|
|
**_skill_event_context_kwargs(context, meta),
|
|
metadata=skill_event_metadata,
|
|
)
|
|
if isinstance(event_row, Mapping) and event_row.get("row_id"):
|
|
skill_event_ref_id = (
|
|
f"skill_event:{meta.skill_id}:invoked:{event_row['row_id']}"
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("Skill invocation quality record failed: %s", exc)
|
|
|
|
modifier = _build_skill_context_modifier(
|
|
meta,
|
|
invocation_args,
|
|
scope_id=skill_scope_id,
|
|
invocation_tool_use_id=invocation_tool_use_id or None,
|
|
skill_event_ref_id=skill_event_ref_id,
|
|
)
|
|
|
|
if meta.execution_context == "fork":
|
|
return await _execute_forked_skill(
|
|
meta=meta,
|
|
content=content,
|
|
args=invocation_args,
|
|
context=context,
|
|
context_modifier=modifier,
|
|
)
|
|
|
|
attachment = create_attachment_message(
|
|
{
|
|
"type": "invoked_skill_content",
|
|
"name": meta.name,
|
|
"skill_id": meta.skill_id,
|
|
"path": str(meta.path),
|
|
"content": content,
|
|
"agent_id": str(getattr(context, "agent_id", "") or ""),
|
|
"allowed_tools": list(meta.allowed_tools),
|
|
"skill_scope_id": skill_scope_id,
|
|
"skill_event_ref_id": skill_event_ref_id,
|
|
"model": meta.model,
|
|
"effort": meta.effort,
|
|
"execution_context": meta.execution_context or "inline",
|
|
}
|
|
)
|
|
result = ToolResult(
|
|
status=ToolStatus.SUCCESS,
|
|
content=f"Launching skill: {meta.name}",
|
|
metadata={
|
|
"tool": self.name,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"skill_scope_id": skill_scope_id,
|
|
"skill_event_ref_id": skill_event_ref_id,
|
|
"allowed_tools": list(meta.allowed_tools),
|
|
"model": meta.model,
|
|
"effort": meta.effort,
|
|
"execution_context": meta.execution_context or "inline",
|
|
},
|
|
)
|
|
setattr(result, "additional_messages", [attachment])
|
|
setattr(result, "context_modifier", modifier)
|
|
return result
|
|
|
|
|
|
class DiscoverSkillsTool(LocalTool):
|
|
"""Search for skills without loading full skill content."""
|
|
|
|
_name = DISCOVER_SKILLS_TOOL_NAME
|
|
_description = "Discover relevant skills by name and description"
|
|
backend_type = BackendType.META
|
|
_is_read_only = True
|
|
_is_concurrency_safe = True
|
|
always_load = True
|
|
search_hint = "find relevant skills"
|
|
parameter_descriptions = {
|
|
"query": (
|
|
"A specific description of the workflow or next action. Use "
|
|
"select:<skill_name> to choose an exact skill already shown in "
|
|
"a listing."
|
|
),
|
|
"max_results": "Maximum number of skill candidates to return.",
|
|
}
|
|
|
|
def __init__(self, discovery_service: SkillDiscoveryService) -> None:
|
|
self._discovery_service = discovery_service
|
|
self._current_context: ToolUseContext | None = None
|
|
super().__init__(verbose=False, handle_errors=False)
|
|
|
|
def set_context(self, context: ToolUseContext) -> None:
|
|
self._current_context = context
|
|
|
|
def get_prompt(self, context: Any = None) -> str:
|
|
return (
|
|
"Discover skills relevant to a planned action. This returns only "
|
|
"candidate skill names and descriptions; use the Skill tool to load "
|
|
"the full instructions before applying a skill. If you already know "
|
|
"the exact skill name, query select:<skill_name>."
|
|
)
|
|
|
|
async def validate_input(
|
|
self,
|
|
input: dict[str, Any],
|
|
context: Any = None,
|
|
) -> str | None:
|
|
if not str(input.get("query") or "").strip():
|
|
return "DiscoverSkills requires a non-empty query"
|
|
return None
|
|
|
|
async def _arun(self, query: str, max_results: int = 5) -> ToolResult:
|
|
try:
|
|
limit = max(1, min(int(max_results), 20))
|
|
except (TypeError, ValueError):
|
|
limit = 5
|
|
hits = await self._discovery_service.asearch(
|
|
query,
|
|
context=self._current_context,
|
|
max_results=limit,
|
|
)
|
|
if self._current_context is not None:
|
|
self._current_context.mark_skills_discovered(hit.name for hit in hits)
|
|
await _record_skill_selection_for_hits(
|
|
self._discovery_service.store,
|
|
self._current_context,
|
|
hits,
|
|
query=query,
|
|
source=self.name,
|
|
method="discover_skills_tool",
|
|
)
|
|
if self._discovery_service.store is not None:
|
|
for hit in hits:
|
|
try:
|
|
await self._discovery_service.store.record_skill_event(
|
|
hit.skill_id,
|
|
"discovered",
|
|
source=self.name,
|
|
**_skill_event_context_kwargs(self._current_context, hit),
|
|
query=query,
|
|
metadata={"query": query, "score": hit.score},
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill discovery event record failed", exc_info=True)
|
|
if not hits:
|
|
return ToolResult(
|
|
status=ToolStatus.SUCCESS,
|
|
content="No relevant skills found for this query.",
|
|
metadata={"tool": self.name, "skills": []},
|
|
)
|
|
|
|
attachment = self._discovery_service.build_attachment(
|
|
hits,
|
|
signal={"query": query},
|
|
)
|
|
lines = [
|
|
"Skills relevant to your task:",
|
|
*[f"- {hit.name}: {hit.description}" for hit in hits],
|
|
"",
|
|
"Use the Skill tool with one of these names to load the full instructions.",
|
|
]
|
|
result = ToolResult(
|
|
status=ToolStatus.SUCCESS,
|
|
content="\n".join(lines),
|
|
metadata={
|
|
"tool": self.name,
|
|
"skills": [
|
|
{"name": hit.name, "skill_id": hit.skill_id}
|
|
for hit in hits
|
|
],
|
|
},
|
|
)
|
|
setattr(result, "additional_messages", [create_attachment_message(attachment)])
|
|
return result
|
|
|
|
|
|
def build_skill_listing_messages(
|
|
context: ToolUseContext,
|
|
*,
|
|
registry: "SkillRegistry",
|
|
tools: Sequence[Any],
|
|
discovery_enabled: bool = True,
|
|
store: "SkillStore | None" = None,
|
|
listing_budget_context_percent: float = SKILL_BUDGET_CONTEXT_PERCENT,
|
|
listing_max_description_chars: int = MAX_LISTING_DESC_CHARS,
|
|
) -> list[dict[str, Any]]:
|
|
service = SkillListingService(
|
|
registry,
|
|
discovery_enabled=discovery_enabled,
|
|
store=store,
|
|
listing_budget_context_percent=listing_budget_context_percent,
|
|
listing_max_description_chars=listing_max_description_chars,
|
|
)
|
|
return [
|
|
create_attachment_message(attachment)
|
|
for attachment in service.get_listing_delta(context, tools)
|
|
]
|
|
|
|
|
|
def build_skill_discovery_messages(
|
|
context: ToolUseContext,
|
|
*,
|
|
registry: "SkillRegistry",
|
|
query: str,
|
|
max_results: int = 5,
|
|
store: "SkillStore | None" = None,
|
|
source: str = "skill_discovery_prefetch",
|
|
tools: Sequence[Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Build OpenSpace automatic ``skill_discovery`` attachment messages.
|
|
|
|
This mirrors OpenSpace's feature-gated discovery surfacing: the backend may be
|
|
OpenSpace's local ranker, but the model only receives candidates
|
|
(name/description/source/id), never full ``SKILL.md`` bodies.
|
|
"""
|
|
|
|
if getattr(context, "skills_disabled", False):
|
|
return []
|
|
active_tools = list(tools if tools is not None else getattr(context, "tools", []) or [])
|
|
if not has_skill_tool(active_tools):
|
|
return []
|
|
|
|
service = SkillDiscoveryService(registry, store=store)
|
|
hits = service.search(query, context=context, max_results=max_results)
|
|
return _build_skill_discovery_messages_from_hits(
|
|
context,
|
|
service=service,
|
|
hits=hits,
|
|
query=query,
|
|
store=store,
|
|
source=source,
|
|
)
|
|
|
|
|
|
async def build_skill_discovery_messages_async(
|
|
context: ToolUseContext,
|
|
*,
|
|
registry: "SkillRegistry",
|
|
query: str,
|
|
max_results: int = 5,
|
|
store: "SkillStore | None" = None,
|
|
source: str = "skill_discovery_prefetch",
|
|
tools: Sequence[Any] | None = None,
|
|
llm_client: Any | None = None,
|
|
model: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Build automatic discovery messages using the legacy LLM selector."""
|
|
|
|
if getattr(context, "skills_disabled", False):
|
|
return []
|
|
active_tools = list(tools if tools is not None else getattr(context, "tools", []) or [])
|
|
if not has_skill_tool(active_tools):
|
|
return []
|
|
|
|
service = SkillDiscoveryService(registry, store=store, llm_client=llm_client)
|
|
hits = await service.asearch(
|
|
query,
|
|
context=context,
|
|
max_results=max_results,
|
|
model=model,
|
|
)
|
|
return _build_skill_discovery_messages_from_hits(
|
|
context,
|
|
service=service,
|
|
hits=hits,
|
|
query=query,
|
|
store=store,
|
|
source=source,
|
|
)
|
|
|
|
|
|
def _build_skill_discovery_messages_from_hits(
|
|
context: ToolUseContext,
|
|
*,
|
|
service: SkillDiscoveryService,
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
query: str,
|
|
store: "SkillStore | None",
|
|
source: str,
|
|
) -> list[dict[str, Any]]:
|
|
if not hits:
|
|
return []
|
|
|
|
context.mark_skills_discovered(hit.name for hit in hits)
|
|
_record_skill_selection_for_hits_now(
|
|
store,
|
|
context,
|
|
hits,
|
|
query=query,
|
|
source=source,
|
|
method="automatic_discovery",
|
|
)
|
|
if store is not None:
|
|
for hit in hits:
|
|
try:
|
|
store.record_skill_event_now(
|
|
hit.skill_id,
|
|
"discovered",
|
|
source=source,
|
|
**_skill_event_context_kwargs(context, hit),
|
|
query=query,
|
|
metadata={"score": hit.score, "skill_name": hit.name},
|
|
)
|
|
except Exception:
|
|
logger.debug("Automatic skill discovery event record failed", exc_info=True)
|
|
|
|
return [
|
|
create_attachment_message(
|
|
service.build_attachment(
|
|
hits,
|
|
signal={"query": query, "source": source},
|
|
)
|
|
)
|
|
]
|
|
|
|
|
|
async def consume_dynamic_skill_triggers(context: ToolUseContext) -> list[dict[str, Any]]:
|
|
"""Consume touched paths and build OpenSpace ``dynamic_skill`` attachments."""
|
|
|
|
registry = getattr(context, "skill_registry", None)
|
|
triggers = getattr(context, "dynamic_skill_path_triggers", None)
|
|
if registry is None or not isinstance(triggers, set) or not triggers:
|
|
return []
|
|
if getattr(context, "skills_disabled", False) or not has_skill_tool(
|
|
getattr(context, "tools", []) or []
|
|
):
|
|
triggers.clear()
|
|
return []
|
|
|
|
paths = sorted(str(path) for path in triggers if path)
|
|
triggers.clear()
|
|
grouped: dict[str, set[str]] = {}
|
|
discovered_metas: dict[str, "SkillMeta"] = {}
|
|
for path in paths:
|
|
try:
|
|
nested = registry.discover_skill_dirs_for_path(path, cwd=context.cwd)
|
|
for skill_dir, skills in nested.items():
|
|
names = grouped.setdefault(skill_dir, set())
|
|
for skill in skills:
|
|
discovered_metas[skill.skill_id] = skill
|
|
names.add(skill.name)
|
|
context.mark_skills_discovered([skill.name])
|
|
context.mark_path_activated_skills([skill.name])
|
|
|
|
conditional = registry.activate_conditional_skills_for_path(
|
|
path,
|
|
cwd=context.cwd,
|
|
)
|
|
for skill in conditional:
|
|
discovered_metas[skill.skill_id] = skill
|
|
key = str(skill.path.parent)
|
|
grouped.setdefault(key, set()).add(skill.name)
|
|
context.mark_skills_discovered([skill.name])
|
|
context.mark_path_activated_skills([skill.name])
|
|
except Exception:
|
|
logger.debug("Dynamic skill trigger failed for %s", path, exc_info=True)
|
|
|
|
store = getattr(context, "skill_store", None)
|
|
if store is not None and discovered_metas:
|
|
try:
|
|
await store.sync_from_registry(list(discovered_metas.values()))
|
|
except Exception:
|
|
logger.debug("Dynamic skill store sync failed", exc_info=True)
|
|
for meta in discovered_metas.values():
|
|
try:
|
|
await store.record_skill_event(
|
|
meta.skill_id,
|
|
"discovered",
|
|
source="dynamic_skill",
|
|
**_skill_event_context_kwargs(context, meta),
|
|
metadata={"trigger": "path"},
|
|
)
|
|
except Exception:
|
|
logger.debug("Dynamic skill discovery event record failed", exc_info=True)
|
|
|
|
sent = getattr(context, "sent_dynamic_skill_keys", set())
|
|
attachments: list[dict[str, Any]] = []
|
|
for skill_dir, names in sorted(grouped.items()):
|
|
skill_names = sorted(names)
|
|
if not skill_names:
|
|
continue
|
|
dedupe_key = f"{skill_dir}:{','.join(skill_names)}"
|
|
if dedupe_key in sent:
|
|
continue
|
|
sent.add(dedupe_key)
|
|
attachments.append(
|
|
create_attachment_message(
|
|
{
|
|
"type": "dynamic_skill",
|
|
"skillDir": skill_dir,
|
|
"displayPath": skill_dir,
|
|
"skillNames": skill_names,
|
|
}
|
|
)
|
|
)
|
|
context.sent_dynamic_skill_keys = sent
|
|
return attachments
|
|
|
|
|
|
def restore_skill_state_from_messages(
|
|
messages: Sequence[Mapping[str, Any]],
|
|
context: ToolUseContext,
|
|
) -> dict[str, int]:
|
|
"""Restore OpenSpace skill protocol runtime state from transcript messages.
|
|
|
|
OpenSpace's conversation recovery scans ``skill_listing`` and ``invoked_skills``
|
|
attachments before resume. OpenSpace keeps the same state on
|
|
``ToolUseContext`` so listing/discovery/invoked-skill retention does not
|
|
duplicate work after compaction or external conversation history replay.
|
|
"""
|
|
|
|
if getattr(context, "skills_disabled", False):
|
|
return {"listed": 0, "discovered": 0, "invoked": 0}
|
|
|
|
agent_key = context.agent_id or "primary"
|
|
sent = context.sent_skill_names_by_agent.setdefault(agent_key, set())
|
|
listed = 0
|
|
discovered = 0
|
|
invoked = 0
|
|
|
|
for message in messages:
|
|
attachment = _message_attachment(message)
|
|
if not attachment:
|
|
invoked += _restore_invoked_skill_from_tool_metadata(message, context)
|
|
continue
|
|
attachment_type = attachment.get("type")
|
|
|
|
if attachment_type == "skill_listing":
|
|
names = _string_items(attachment.get("skillNames"))
|
|
if not names:
|
|
names = _skill_names_from_listing_content(attachment.get("content"))
|
|
if names:
|
|
before = len(sent)
|
|
sent.update(names)
|
|
listed += len(sent) - before
|
|
continue
|
|
|
|
if attachment_type == "skill_discovery":
|
|
names = []
|
|
for item in attachment.get("skills") or []:
|
|
if isinstance(item, Mapping):
|
|
name = str(item.get("name") or "").strip()
|
|
if name:
|
|
names.append(name)
|
|
if names:
|
|
before = len(context.discovered_skill_names)
|
|
context.mark_skills_discovered(names)
|
|
discovered += len(context.discovered_skill_names) - before
|
|
continue
|
|
|
|
if attachment_type == "dynamic_skill":
|
|
names = _string_items(attachment.get("skillNames"))
|
|
if names:
|
|
before = len(context.discovered_skill_names)
|
|
context.mark_skills_discovered(names)
|
|
context.mark_path_activated_skills(names)
|
|
discovered += len(context.discovered_skill_names) - before
|
|
skill_dir = str(attachment.get("skillDir") or attachment.get("displayPath") or "")
|
|
if skill_dir:
|
|
context.sent_dynamic_skill_keys.add(
|
|
f"{skill_dir}:{','.join(sorted(names))}"
|
|
)
|
|
continue
|
|
|
|
if attachment_type == "skill_state":
|
|
listed += _restore_skill_state_attachment(attachment, context)
|
|
discovered += _restore_skill_state_discovered_count(attachment, context)
|
|
continue
|
|
|
|
if attachment_type == "invoked_skill_content":
|
|
record = _record_from_invoked_attachment(attachment)
|
|
if record is not None:
|
|
record_agent_id = str(attachment.get("agent_id") or "") or None
|
|
context.record_invoked_skill(
|
|
record,
|
|
agent_id=record_agent_id,
|
|
)
|
|
_restore_invoked_skill_scope(record, context, agent_id=record_agent_id)
|
|
context.mark_skills_discovered([record.name])
|
|
invoked += 1
|
|
continue
|
|
|
|
if attachment_type == "invoked_skills":
|
|
for item in attachment.get("skills") or []:
|
|
if not isinstance(item, Mapping):
|
|
continue
|
|
record = _record_from_invoked_attachment(item)
|
|
if record is None:
|
|
continue
|
|
record_agent_id = str(item.get("agent_id") or "") or None
|
|
context.record_invoked_skill(
|
|
record,
|
|
agent_id=record_agent_id,
|
|
)
|
|
_restore_invoked_skill_scope(record, context, agent_id=record_agent_id)
|
|
context.mark_skills_discovered([record.name])
|
|
invoked += 1
|
|
|
|
invoked += _restore_invoked_skill_from_tool_metadata(message, context)
|
|
|
|
return {
|
|
"listed": listed,
|
|
"discovered": discovered,
|
|
"invoked": invoked,
|
|
}
|
|
|
|
|
|
def _restore_skill_state_attachment(
|
|
attachment: Mapping[str, Any],
|
|
context: ToolUseContext,
|
|
) -> int:
|
|
listed = 0
|
|
raw_sent = attachment.get("sentSkillNamesByAgent")
|
|
if isinstance(raw_sent, Mapping):
|
|
for agent, names in raw_sent.items():
|
|
restored_names = _string_items(names)
|
|
if not restored_names:
|
|
continue
|
|
sent = context.sent_skill_names_by_agent.setdefault(
|
|
str(agent or "primary"),
|
|
set(),
|
|
)
|
|
before = len(sent)
|
|
sent.update(restored_names)
|
|
listed += len(sent) - before
|
|
|
|
for key in _string_items(attachment.get("sentDynamicSkillKeys")):
|
|
context.sent_dynamic_skill_keys.add(key)
|
|
context.mark_path_activated_skills(
|
|
_string_items(attachment.get("pathActivatedSkillNames"))
|
|
)
|
|
return listed
|
|
|
|
|
|
def _restore_skill_state_discovered_count(
|
|
attachment: Mapping[str, Any],
|
|
context: ToolUseContext,
|
|
) -> int:
|
|
before = len(context.discovered_skill_names)
|
|
context.mark_skills_discovered(_string_items(attachment.get("discoveredSkillNames")))
|
|
return len(context.discovered_skill_names) - before
|
|
|
|
|
|
def _normalize_skill_name(value: str) -> str:
|
|
trimmed = str(value or "").strip()
|
|
return trimmed[1:] if trimmed.startswith("/") else trimmed
|
|
|
|
|
|
def _canonical_skill_key(value: Any) -> str:
|
|
return re.sub(r"[\s_/]+", "-", str(value or "").strip().lower())
|
|
|
|
|
|
def _skill_lookup_values(skill: "SkillMeta") -> set[str]:
|
|
values = {
|
|
str(getattr(skill, "name", "") or ""),
|
|
str(getattr(skill, "skill_id", "") or ""),
|
|
str(getattr(skill, "display_name", "") or ""),
|
|
}
|
|
return {value for value in values if value}
|
|
|
|
|
|
def _skill_matches_exact(skill: "SkillMeta", query: str) -> bool:
|
|
normalized = _canonical_skill_key(query)
|
|
if not normalized:
|
|
return False
|
|
return any(_canonical_skill_key(value) == normalized for value in _skill_lookup_values(skill))
|
|
|
|
|
|
def _skill_matches_prefix(skill: "SkillMeta", query: str) -> bool:
|
|
normalized = _canonical_skill_key(query)
|
|
if not normalized:
|
|
return False
|
|
return any(
|
|
_canonical_skill_key(value).startswith(normalized)
|
|
for value in _skill_lookup_values(skill)
|
|
)
|
|
|
|
|
|
def _hit_for_skill(skill: "SkillMeta", score: float) -> SkillDiscoveryHit:
|
|
return SkillDiscoveryHit(
|
|
name=skill.name,
|
|
description=skill.description,
|
|
skill_id=skill.skill_id,
|
|
source=skill.source,
|
|
score=float(score),
|
|
)
|
|
|
|
|
|
def _skill_selection_context_kwargs(context: Any | None) -> dict[str, str]:
|
|
kwargs = _skill_event_context_kwargs(context, None)
|
|
kwargs.pop("skill_name", None)
|
|
return kwargs
|
|
|
|
|
|
def _skill_selection_record_from_hits(
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
*,
|
|
query: str,
|
|
source: str,
|
|
method: str,
|
|
) -> dict[str, Any]:
|
|
selected = [hit.skill_id for hit in hits if hit.skill_id]
|
|
return {
|
|
"method": method,
|
|
"source": source,
|
|
"task": str(query or "")[:500],
|
|
"available_skills": selected,
|
|
"selected": selected,
|
|
"scores": [
|
|
{
|
|
"skill_id": hit.skill_id,
|
|
"name": hit.name,
|
|
"score": hit.score,
|
|
}
|
|
for hit in hits
|
|
],
|
|
}
|
|
|
|
|
|
async def _record_skill_selection_for_hits(
|
|
store: Any | None,
|
|
context: Any | None,
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
*,
|
|
query: str,
|
|
source: str,
|
|
method: str,
|
|
) -> None:
|
|
skill_ids = [hit.skill_id for hit in hits if hit.skill_id]
|
|
if not skill_ids:
|
|
return
|
|
context_kwargs = _skill_selection_context_kwargs(context)
|
|
if store is not None:
|
|
try:
|
|
await store.record_skill_selection(
|
|
skill_ids,
|
|
source=source,
|
|
**context_kwargs,
|
|
query=query,
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill selection event record failed", exc_info=True)
|
|
try:
|
|
from openspace.recording import RecordingManager
|
|
|
|
await RecordingManager.record_skill_selection(
|
|
_skill_selection_record_from_hits(
|
|
hits,
|
|
query=query,
|
|
source=source,
|
|
method=method,
|
|
)
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill selection metadata record failed", exc_info=True)
|
|
|
|
|
|
def _record_skill_selection_for_hits_now(
|
|
store: Any | None,
|
|
context: Any | None,
|
|
hits: Sequence[SkillDiscoveryHit],
|
|
*,
|
|
query: str,
|
|
source: str,
|
|
method: str,
|
|
) -> None:
|
|
skill_ids = [hit.skill_id for hit in hits if hit.skill_id]
|
|
if not skill_ids:
|
|
return
|
|
context_kwargs = _skill_selection_context_kwargs(context)
|
|
if store is not None:
|
|
try:
|
|
store.record_skill_selection_now(
|
|
skill_ids,
|
|
source=source,
|
|
**context_kwargs,
|
|
query=query,
|
|
)
|
|
except Exception:
|
|
logger.debug("Automatic skill selection event record failed", exc_info=True)
|
|
try:
|
|
from openspace.recording import RecordingManager
|
|
|
|
RecordingManager.record_skill_selection_now(
|
|
_skill_selection_record_from_hits(
|
|
hits,
|
|
query=query,
|
|
source=source,
|
|
method=method,
|
|
)
|
|
)
|
|
except Exception:
|
|
logger.debug("Automatic skill selection metadata record failed", exc_info=True)
|
|
|
|
|
|
def _skill_event_context_kwargs(
|
|
context: Any | None,
|
|
skill: Any | None = None,
|
|
) -> dict[str, str]:
|
|
"""Build common skill_events attribution fields from ToolUseContext."""
|
|
|
|
task_id = ""
|
|
turn_id = ""
|
|
agent_id = ""
|
|
if context is not None:
|
|
task_id = str(
|
|
getattr(context, "task_id", None)
|
|
or getattr(context, "parent_task_id", None)
|
|
or ""
|
|
)
|
|
turn_value = getattr(context, "current_iteration", None)
|
|
if turn_value is not None:
|
|
turn_id = str(turn_value)
|
|
agent_id = str(getattr(context, "agent_id", None) or "")
|
|
|
|
return {
|
|
"task_id": task_id,
|
|
"turn_id": turn_id,
|
|
"agent_id": agent_id,
|
|
"skill_name": str(getattr(skill, "name", "") or ""),
|
|
}
|
|
|
|
|
|
def _tool_event_ref_id_for_context(
|
|
context: Any | None,
|
|
tool_use_id: str | None,
|
|
) -> str | None:
|
|
tool_use_id = str(tool_use_id or "").strip()
|
|
if context is None or not tool_use_id:
|
|
return None
|
|
session_id = str(getattr(context, "session_id", None) or "none")
|
|
task_id = str(
|
|
getattr(context, "task_id", None)
|
|
or getattr(context, "parent_task_id", None)
|
|
or "none"
|
|
)
|
|
agent_id = str(getattr(context, "agent_id", None) or "primary")
|
|
return f"tool_event:{session_id}:{task_id}:{agent_id}:{tool_use_id}"
|
|
|
|
|
|
async def _record_skill_event_for_context(
|
|
store: Any | None,
|
|
skill: Any | None,
|
|
event_type: str,
|
|
*,
|
|
source: str,
|
|
context: Any | None = None,
|
|
query: str = "",
|
|
metadata: Mapping[str, Any] | None = None,
|
|
) -> None:
|
|
if store is None or skill is None:
|
|
return
|
|
try:
|
|
event_metadata = dict(metadata or {})
|
|
if context is not None and getattr(context, "session_id", None):
|
|
event_metadata.setdefault("session_id", str(context.session_id))
|
|
await store.record_skill_event(
|
|
skill.skill_id,
|
|
event_type,
|
|
source=source,
|
|
**_skill_event_context_kwargs(context, skill),
|
|
query=query,
|
|
metadata=event_metadata,
|
|
)
|
|
except Exception:
|
|
logger.debug("Skill event record failed", exc_info=True)
|
|
|
|
|
|
async def record_skill_permission_decision_for_context(
|
|
context: Any | None,
|
|
input: Mapping[str, Any] | None,
|
|
event_type: str,
|
|
*,
|
|
source: str,
|
|
metadata: Mapping[str, Any] | None = None,
|
|
) -> None:
|
|
"""Record a Skill permission decision from the shared tool pipeline."""
|
|
|
|
if context is None or input is None:
|
|
return
|
|
store = getattr(context, "skill_store", None)
|
|
registry = getattr(context, "skill_registry", None)
|
|
if store is None or registry is None:
|
|
return
|
|
skill_name = _normalize_skill_name(str(input.get("skill") or ""))
|
|
if not skill_name:
|
|
return
|
|
try:
|
|
meta = registry.resolve_skill_for_model(skill_name)
|
|
except Exception:
|
|
meta = None
|
|
await _record_skill_event_for_context(
|
|
store,
|
|
meta,
|
|
event_type,
|
|
source=source,
|
|
context=context,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _skill_rule_content_matches(rule_content: str | None, skill_name: str) -> bool:
|
|
if rule_content is None:
|
|
return True
|
|
normalized_rule = str(rule_content).strip()
|
|
if normalized_rule.startswith("/"):
|
|
normalized_rule = normalized_rule[1:]
|
|
normalized_skill = _normalize_skill_name(skill_name)
|
|
if normalized_rule == normalized_skill:
|
|
return True
|
|
if normalized_rule.endswith(":*"):
|
|
return normalized_skill.startswith(normalized_rule[:-2])
|
|
return False
|
|
|
|
|
|
def _matching_skill_rule(
|
|
context: ToolPermissionContext,
|
|
skill_name: str,
|
|
behavior: str,
|
|
):
|
|
if behavior == "deny":
|
|
from openspace.grounding.core.permissions import get_deny_rules as get_rules
|
|
elif behavior == "allow":
|
|
from openspace.grounding.core.permissions import get_allow_rules as get_rules
|
|
else:
|
|
from openspace.grounding.core.permissions import get_ask_rules as get_rules
|
|
|
|
for rule in get_rules(context):
|
|
value = rule.rule_value
|
|
if value.tool_name != SKILL_TOOL_NAME:
|
|
continue
|
|
if _skill_rule_content_matches(value.rule_content, skill_name):
|
|
return rule
|
|
return None
|
|
|
|
|
|
def _skill_has_only_safe_properties(skill: "SkillMeta") -> bool:
|
|
"""Return whether a skill can load without an interactive permission ask.
|
|
|
|
Matches OpenSpace's safe-property intent while keeping OS high-risk runtime fields
|
|
explicit: ``allowed-tools``, ``hooks``, ``shell`` and unknown meaningful
|
|
frontmatter require ask unless an allow rule exists.
|
|
"""
|
|
|
|
return not (
|
|
skill.allowed_tools
|
|
or skill.hooks
|
|
or skill.shell
|
|
or skill.model
|
|
or skill.effort
|
|
or skill.execution_context
|
|
or skill.agent
|
|
or skill.unknown_fields
|
|
)
|
|
|
|
|
|
def _skill_permission_ask_message(skill: "SkillMeta") -> str:
|
|
details: list[str] = []
|
|
if skill.allowed_tools:
|
|
details.append("allowed-tools=" + ", ".join(skill.allowed_tools))
|
|
if skill.hooks:
|
|
hook_events = ", ".join(str(key) for key in skill.hooks.keys())
|
|
http_urls = _skill_http_hook_urls(skill.hooks)
|
|
hook_text = f"hooks={hook_events or 'configured'}"
|
|
if http_urls:
|
|
hook_text += "; http=" + ", ".join(http_urls)
|
|
details.append(hook_text)
|
|
if skill.shell:
|
|
details.append(f"shell={skill.shell}")
|
|
if skill.model:
|
|
details.append(f"model={skill.model}")
|
|
if skill.effort:
|
|
details.append(f"effort={skill.effort}")
|
|
if skill.execution_context:
|
|
details.append(f"context={skill.execution_context}")
|
|
if skill.agent:
|
|
details.append(f"agent={skill.agent}")
|
|
if skill.unknown_fields:
|
|
details.append("unknown-fields=" + ", ".join(sorted(skill.unknown_fields)))
|
|
suffix = f" ({'; '.join(details)})" if details else ""
|
|
return f"Execute skill: {skill.name}{suffix}"
|
|
|
|
|
|
def _skill_http_hook_urls(hooks: Mapping[str, Any]) -> list[str]:
|
|
urls: list[str] = []
|
|
for matchers in hooks.values():
|
|
if not isinstance(matchers, Sequence) or isinstance(matchers, (str, bytes, bytearray)):
|
|
continue
|
|
for matcher in matchers:
|
|
if not isinstance(matcher, Mapping):
|
|
continue
|
|
for hook in matcher.get("hooks") or []:
|
|
if isinstance(hook, Mapping) and str(hook.get("type") or "") == "http":
|
|
url = str(hook.get("url") or "").strip()
|
|
if url:
|
|
urls.append(url)
|
|
return urls
|
|
|
|
|
|
def _message_attachment(message: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
meta = message.get("_meta")
|
|
if not isinstance(meta, Mapping):
|
|
return None
|
|
attachment = meta.get("attachment")
|
|
return attachment if isinstance(attachment, Mapping) else None
|
|
|
|
|
|
def _string_items(value: Any) -> list[str]:
|
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
|
|
return []
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
|
|
|
|
def _skill_names_from_listing_content(value: Any) -> list[str]:
|
|
if not isinstance(value, str):
|
|
return []
|
|
names: list[str] = []
|
|
for line in value.splitlines():
|
|
match = re.match(r"\s*-\s*([^:\n]+?)(?::|\s*$)", line)
|
|
if match:
|
|
name = match.group(1).strip()
|
|
if name:
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def _record_from_invoked_attachment(item: Mapping[str, Any]) -> SkillInvocationRecord | None:
|
|
name = str(item.get("name") or "").strip()
|
|
content = str(item.get("content") or "").strip()
|
|
if not name or not content:
|
|
return None
|
|
return SkillInvocationRecord(
|
|
skill_id=str(item.get("skill_id") or item.get("skillId") or name),
|
|
name=name,
|
|
path=str(item.get("path") or ""),
|
|
content=content,
|
|
allowed_tools=_string_items(item.get("allowed_tools") or item.get("allowedTools")),
|
|
model=str(item.get("model")).strip() if item.get("model") else None,
|
|
effort=str(item.get("effort")).strip() if item.get("effort") else None,
|
|
execution_context=str(
|
|
item.get("execution_context") or item.get("executionContext") or "inline"
|
|
),
|
|
)
|
|
|
|
|
|
def _restore_invoked_skill_scope(
|
|
record: SkillInvocationRecord,
|
|
context: ToolUseContext,
|
|
*,
|
|
agent_id: str | None = None,
|
|
) -> None:
|
|
"""Restore task-local runtime modifiers for a retained inline skill."""
|
|
|
|
if record.execution_context == "fork":
|
|
return
|
|
if not (record.allowed_tools or record.model or record.effort):
|
|
return
|
|
agent_key = agent_id or context.agent_id or "primary"
|
|
scope = SkillInvocationScope(
|
|
scope_id=f"restored:{agent_key}:{record.skill_id}",
|
|
skill_id=record.skill_id,
|
|
name=record.name,
|
|
args=record.args,
|
|
execution_mode=record.execution_context or "inline",
|
|
allowed_tools_delta=list(record.allowed_tools),
|
|
model_override=record.model,
|
|
effort_override=record.effort,
|
|
created_turn=int(getattr(context, "current_iteration", 0) or 0),
|
|
permission_decision="restored",
|
|
)
|
|
context.activate_skill_scope(scope)
|
|
|
|
|
|
def _restore_invoked_skill_from_tool_metadata(
|
|
message: Mapping[str, Any],
|
|
context: ToolUseContext,
|
|
) -> int:
|
|
meta = message.get("_meta")
|
|
if not isinstance(meta, Mapping):
|
|
return 0
|
|
result_meta = meta.get("tool_result_metadata")
|
|
if not isinstance(result_meta, Mapping) or result_meta.get("tool") != SKILL_TOOL_NAME:
|
|
return 0
|
|
skill_id = str(result_meta.get("skill_id") or "").strip()
|
|
if not skill_id:
|
|
return 0
|
|
|
|
registry = getattr(context, "skill_registry", None)
|
|
if registry is None:
|
|
return 0
|
|
try:
|
|
skill = registry.get_skill(skill_id)
|
|
if skill is None:
|
|
return 0
|
|
content = registry.build_skill_invocation_context(skill, args="")
|
|
except Exception:
|
|
return 0
|
|
if not content:
|
|
return 0
|
|
|
|
context.record_invoked_skill(
|
|
SkillInvocationRecord(
|
|
skill_id=skill.skill_id,
|
|
name=skill.name,
|
|
path=str(skill.path),
|
|
content=content,
|
|
allowed_tools=list(skill.allowed_tools),
|
|
model=skill.model,
|
|
effort=skill.effort,
|
|
execution_context=skill.execution_context or "inline",
|
|
)
|
|
)
|
|
context.mark_skills_discovered([skill.name])
|
|
return 1
|
|
|
|
|
|
def _skill_visible_to_context(skill: "SkillMeta", context: Any | None) -> bool:
|
|
"""Return whether a conditional ``paths`` skill is model-visible now."""
|
|
|
|
if not skill.conditional_paths:
|
|
return True
|
|
if context is None:
|
|
return False
|
|
if skill.name in getattr(context, "path_activated_skill_names", set()):
|
|
return True
|
|
for records in (getattr(context, "invoked_skills_by_agent", {}) or {}).values():
|
|
for record in records:
|
|
if (
|
|
getattr(record, "skill_id", None) == skill.skill_id
|
|
or getattr(record, "name", None) == skill.name
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _append_skill_allowed_tools(
|
|
context: ToolUseContext,
|
|
allowed_tools: Sequence[str],
|
|
) -> None:
|
|
perm_ctx = getattr(context, "permission_context", None)
|
|
if perm_ctx is None or not allowed_tools:
|
|
return
|
|
|
|
allow_rules = {
|
|
source: list(rules)
|
|
for source, rules in perm_ctx.always_allow_rules.items()
|
|
}
|
|
command_rules = allow_rules.setdefault("command", [])
|
|
for rule in allowed_tools:
|
|
raw = str(rule).strip()
|
|
if raw and raw not in command_rules:
|
|
command_rules.append(raw)
|
|
|
|
context.permission_context = ToolPermissionContext(
|
|
mode=perm_ctx.mode,
|
|
additional_working_directories=perm_ctx.additional_working_directories,
|
|
always_allow_rules={
|
|
source: tuple(rules)
|
|
for source, rules in allow_rules.items()
|
|
},
|
|
always_deny_rules=perm_ctx.always_deny_rules,
|
|
always_ask_rules=perm_ctx.always_ask_rules,
|
|
is_bypass_permissions_mode_available=perm_ctx.is_bypass_permissions_mode_available,
|
|
stripped_dangerous_rules=perm_ctx.stripped_dangerous_rules,
|
|
should_avoid_permission_prompts=perm_ctx.should_avoid_permission_prompts,
|
|
await_automated_checks_before_dialog=perm_ctx.await_automated_checks_before_dialog,
|
|
pre_plan_mode=perm_ctx.pre_plan_mode,
|
|
)
|
|
|
|
|
|
def _build_skill_context_modifier(
|
|
meta: "SkillMeta",
|
|
args: str,
|
|
*,
|
|
scope_id: str | None = None,
|
|
invocation_tool_use_id: str | None = None,
|
|
skill_event_ref_id: str | None = None,
|
|
):
|
|
def _modifier(context: ToolUseContext) -> ToolUseContext:
|
|
scope = SkillInvocationScope(
|
|
scope_id=scope_id or f"skill_{uuid.uuid4().hex[:12]}",
|
|
skill_id=meta.skill_id,
|
|
name=meta.name,
|
|
args=args,
|
|
source=meta.source,
|
|
loaded_from=meta.loaded_from,
|
|
execution_mode=meta.execution_context or "inline",
|
|
allowed_tools_delta=list(meta.allowed_tools),
|
|
model_override=meta.model,
|
|
effort_override=meta.effort,
|
|
agent_type=meta.agent,
|
|
hooks_enabled=bool(meta.hooks),
|
|
shell=meta.shell,
|
|
invocation_tool_use_id=invocation_tool_use_id,
|
|
skill_event_ref_id=skill_event_ref_id,
|
|
created_turn=getattr(context, "current_iteration", 0),
|
|
)
|
|
context.activate_skill_scope(scope)
|
|
_register_skill_hooks_for_scope(context, meta, scope)
|
|
return context
|
|
|
|
return _modifier
|
|
|
|
|
|
def _register_skill_hooks_for_scope(
|
|
context: ToolUseContext,
|
|
meta: "SkillMeta",
|
|
scope: SkillInvocationScope,
|
|
) -> None:
|
|
hooks = getattr(meta, "hooks", None)
|
|
hook_registry = getattr(context, "hook_registry", None)
|
|
if not isinstance(hooks, Mapping) or hook_registry is None:
|
|
return
|
|
|
|
try:
|
|
from openspace.services.tooling.hooks import HookEvent
|
|
except Exception:
|
|
return
|
|
|
|
registered = 0
|
|
for event_name, matchers in hooks.items():
|
|
try:
|
|
event = HookEvent(str(event_name))
|
|
except Exception:
|
|
logger.debug("Skill hook event %r is not supported by OS", event_name)
|
|
continue
|
|
if not isinstance(matchers, Sequence) or isinstance(matchers, (str, bytes, bytearray)):
|
|
continue
|
|
for matcher in matchers:
|
|
if not isinstance(matcher, Mapping):
|
|
continue
|
|
matcher_value = str(matcher.get("matcher") or "").strip()
|
|
tool_name = _skill_hook_registration_tool_name(event.value, matcher_value)
|
|
for hook in matcher.get("hooks") or []:
|
|
if not isinstance(hook, Mapping):
|
|
continue
|
|
hook_type = str(hook.get("type") or "command")
|
|
if hook_type not in {"command", "prompt", "http", "agent"}:
|
|
logger.debug(
|
|
"Skill hook %s:%s uses unsupported hook type %r",
|
|
meta.name,
|
|
event.value,
|
|
hook.get("type"),
|
|
)
|
|
continue
|
|
if not _skill_hook_has_payload(hook_type, hook):
|
|
continue
|
|
callback = _build_skill_hook_callback(
|
|
meta=meta,
|
|
event=event.value,
|
|
matcher=matcher_value,
|
|
hook=dict(hook),
|
|
hook_registry=hook_registry,
|
|
)
|
|
try:
|
|
registration = hook_registry.register(
|
|
event,
|
|
callback,
|
|
tool_name=tool_name,
|
|
priority=90,
|
|
name=f"skill:{meta.name}:{event.value}:{registered}",
|
|
once=False,
|
|
)
|
|
setattr(callback, "_skill_hook_registration", registration)
|
|
except Exception:
|
|
logger.debug("Skill hook registration failed", exc_info=True)
|
|
continue
|
|
scope.hook_registrations.append(registration)
|
|
registered += 1
|
|
|
|
if registered:
|
|
logger.info("Registered %d hook(s) from skill %s", registered, meta.name)
|
|
|
|
|
|
def _skill_hook_registration_tool_name(event: str, matcher: str) -> str | None:
|
|
"""Return an exact tool-name filter when the matcher is safe to prefilter.
|
|
|
|
OpenSpace's matcher language allows wildcard, pipe-separated exact lists and regex.
|
|
OS HookRegistry only supports exact tool-name prefiltering, so complex
|
|
matchers register globally and are filtered inside the skill hook callback.
|
|
"""
|
|
if event not in {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionDenied",
|
|
"PermissionRequest",
|
|
}:
|
|
return None
|
|
if not matcher:
|
|
return None
|
|
raw = matcher.strip()
|
|
if raw == "*" or "|" in raw:
|
|
return None
|
|
if re.search(r"[\\^$.*+?\[\]{}]", raw):
|
|
return None
|
|
try:
|
|
rule = parse_rule_value(raw)
|
|
except Exception:
|
|
return None
|
|
return rule.tool_name
|
|
|
|
|
|
def _skill_hook_has_payload(hook_type: str, hook: Mapping[str, Any]) -> bool:
|
|
if hook_type == "command":
|
|
return bool(str(hook.get("command") or "").strip())
|
|
if hook_type == "prompt":
|
|
return bool(str(hook.get("prompt") or "").strip())
|
|
if hook_type == "http":
|
|
return bool(str(hook.get("url") or "").strip())
|
|
if hook_type == "agent":
|
|
return bool(str(hook.get("prompt") or "").strip())
|
|
return False
|
|
|
|
|
|
def _build_skill_hook_callback(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
matcher: str,
|
|
hook: Mapping[str, Any],
|
|
hook_registry: Any,
|
|
):
|
|
async def _finish(result):
|
|
if (
|
|
bool(hook.get("once"))
|
|
and getattr(result, "outcome", "success") == "success"
|
|
and getattr(result, "blocking_error", None) is None
|
|
):
|
|
registration = getattr(_callback, "_skill_hook_registration", None)
|
|
if registration is not None:
|
|
try:
|
|
hook_registry.unregister(registration)
|
|
except Exception:
|
|
logger.debug("Skill hook once unregister failed", exc_info=True)
|
|
return result
|
|
|
|
async def _callback(
|
|
tool_name: str = "",
|
|
tool_input: dict[str, Any] | None = None,
|
|
tool_use_id: str = "",
|
|
context: ToolUseContext | None = None,
|
|
**kwargs: Any,
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
hook_input = tool_input or {}
|
|
if not _skill_hook_matcher_matches(
|
|
matcher,
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=hook_input,
|
|
kwargs=kwargs,
|
|
):
|
|
return HookResult()
|
|
if hook.get("if") and not _skill_hook_condition_matches(
|
|
hook.get("if"),
|
|
tool_name,
|
|
hook_input,
|
|
):
|
|
return HookResult()
|
|
if context is None:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
"Skill hook requires ToolUseContext",
|
|
command=str(hook.get("command") or hook.get("prompt") or hook.get("url") or ""),
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
hook_type = str(hook.get("type") or "command")
|
|
if hook_type == "prompt":
|
|
return await _finish(await _execute_skill_prompt_hook(
|
|
meta=meta,
|
|
event=event,
|
|
hook=hook,
|
|
tool_name=tool_name,
|
|
tool_input=hook_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
))
|
|
if hook_type == "http":
|
|
return await _finish(await _execute_skill_http_hook(
|
|
meta=meta,
|
|
event=event,
|
|
hook=hook,
|
|
tool_name=tool_name,
|
|
tool_input=hook_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
))
|
|
if hook_type == "agent":
|
|
return await _finish(await _execute_skill_agent_hook(
|
|
meta=meta,
|
|
event=event,
|
|
hook=hook,
|
|
tool_name=tool_name,
|
|
tool_input=hook_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
))
|
|
|
|
shell_tool = _find_skill_prompt_shell_tool(
|
|
context,
|
|
str(hook.get("shell") or meta.shell or "bash").strip().lower() or None,
|
|
)
|
|
command = _render_skill_hook_command(
|
|
str(hook.get("command") or ""),
|
|
meta=meta,
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=hook_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
if shell_tool is None:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
"Skill command hook requires bash/powershell tool",
|
|
command=command,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
if hook.get("async") is True or hook.get("asyncRewake") is True:
|
|
return await _finish(
|
|
_schedule_skill_async_command_hook(
|
|
meta=meta,
|
|
event=event,
|
|
hook=hook,
|
|
shell_tool=shell_tool,
|
|
command=command,
|
|
context=context,
|
|
)
|
|
)
|
|
|
|
result = await _execute_skill_hook_command_via_runtime(
|
|
shell_tool=shell_tool,
|
|
command=command,
|
|
context=context,
|
|
timeout=int(hook.get("timeout") or 30),
|
|
description=str(hook.get("statusMessage") or f"Skill hook: {meta.name}"),
|
|
)
|
|
output = extract_text_from_content(result.content).strip()
|
|
if result.status != ToolStatus.SUCCESS:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
output or result.error or "Skill hook command failed",
|
|
command=command,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
if _looks_like_hook_json(output):
|
|
return await _finish(_hook_result_from_ok_json(
|
|
output,
|
|
hook_label=f"Skill command hook `{meta.name}` ({event})",
|
|
command=command,
|
|
event=event,
|
|
))
|
|
if output:
|
|
return await _finish(HookResult(
|
|
system_message=(
|
|
f"Skill hook `{meta.name}` ({event}) output:\n{output}"
|
|
)
|
|
))
|
|
return await _finish(HookResult())
|
|
|
|
return _callback
|
|
|
|
|
|
def _schedule_skill_async_command_hook(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
hook: Mapping[str, Any],
|
|
shell_tool: Any,
|
|
command: str,
|
|
context: ToolUseContext,
|
|
):
|
|
from openspace.services.tooling.hooks import HookResult
|
|
|
|
async def _runner() -> None:
|
|
hook_name = f"skill:{meta.name}:{event}:async"
|
|
try:
|
|
await context.emit_event(
|
|
"hook_async_start",
|
|
{
|
|
"hook_name": hook_name,
|
|
"hook_event": event,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"command": command,
|
|
"async_rewake": bool(hook.get("asyncRewake")),
|
|
},
|
|
)
|
|
result = await _execute_skill_hook_command_via_runtime(
|
|
shell_tool=shell_tool,
|
|
command=command,
|
|
context=context,
|
|
timeout=int(hook.get("timeout") or 30),
|
|
description=str(
|
|
hook.get("statusMessage") or f"Skill hook: {meta.name}"
|
|
),
|
|
run_in_background=True,
|
|
)
|
|
output = extract_text_from_content(result.content).strip()
|
|
await _maybe_queue_async_rewake_notification(
|
|
meta=meta,
|
|
event=event,
|
|
hook=hook,
|
|
command=command,
|
|
output=output,
|
|
result=result,
|
|
context=context,
|
|
)
|
|
await context.emit_event(
|
|
"hook_async_complete",
|
|
{
|
|
"hook_name": hook_name,
|
|
"hook_event": event,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"success": result.status == ToolStatus.SUCCESS,
|
|
"output": output,
|
|
"error": result.error,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("Skill async command hook failed", exc_info=True)
|
|
await context.emit_event(
|
|
"hook_async_complete",
|
|
{
|
|
"hook_name": f"skill:{meta.name}:{event}:async",
|
|
"hook_event": event,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"success": False,
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
|
|
task = asyncio.create_task(_runner())
|
|
tasks = getattr(context, "background_hook_tasks", None)
|
|
if isinstance(tasks, set):
|
|
tasks.add(task)
|
|
task.add_done_callback(tasks.discard)
|
|
return HookResult(
|
|
system_message=(
|
|
f"Skill async hook `{meta.name}` ({event}) started in background."
|
|
)
|
|
)
|
|
|
|
|
|
def _skill_hook_tool_runtime_context(context: ToolUseContext) -> ToolUseContext:
|
|
from openspace.services.tooling.hooks import HookRegistry, setup_default_hooks
|
|
|
|
hook_registry = HookRegistry()
|
|
setup_default_hooks(hook_registry)
|
|
return replace(
|
|
context,
|
|
hook_registry=hook_registry,
|
|
agent_id=f"{context.agent_id}:skill_hook",
|
|
)
|
|
|
|
|
|
async def _execute_skill_hook_command_via_runtime(
|
|
*,
|
|
shell_tool: Any,
|
|
command: str,
|
|
context: ToolUseContext,
|
|
timeout: int,
|
|
description: str,
|
|
run_in_background: bool = False,
|
|
) -> ToolResult:
|
|
from openspace.tool_runtime.pipeline.execution import (
|
|
run_tool_use,
|
|
tool_call_result_to_tool_result,
|
|
)
|
|
|
|
tool_name = str(getattr(shell_tool, "name", "") or "")
|
|
if not tool_name:
|
|
return ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content="Skill command hook requires a named shell tool",
|
|
error="missing shell tool name",
|
|
metadata={"hook_command": True},
|
|
)
|
|
|
|
hook_context = _skill_hook_tool_runtime_context(context)
|
|
tool_input: dict[str, Any] = {
|
|
"command": command,
|
|
"timeout": timeout,
|
|
"description": description,
|
|
"_skill_hook_command": True,
|
|
}
|
|
if run_in_background:
|
|
tool_input["run_in_background"] = True
|
|
|
|
previous_context = getattr(shell_tool, "_current_context", None)
|
|
try:
|
|
result = await run_tool_use(
|
|
{
|
|
"id": f"skill_hook_{uuid.uuid4().hex[:12]}",
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool_name,
|
|
"arguments": tool_input,
|
|
},
|
|
},
|
|
{tool_name: shell_tool},
|
|
hook_context,
|
|
)
|
|
finally:
|
|
if hasattr(shell_tool, "set_context"):
|
|
shell_tool.set_context(previous_context)
|
|
|
|
tool_result = tool_call_result_to_tool_result(result)
|
|
tool_result.metadata = {
|
|
**(tool_result.metadata or {}),
|
|
"hook_command": True,
|
|
"runtime_hook_command": True,
|
|
}
|
|
return tool_result
|
|
|
|
|
|
async def _maybe_queue_async_rewake_notification(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
hook: Mapping[str, Any],
|
|
command: str,
|
|
output: str,
|
|
result: ToolResult,
|
|
context: ToolUseContext,
|
|
) -> None:
|
|
if hook.get("asyncRewake") is not True:
|
|
return
|
|
metadata = getattr(result, "metadata", None) or {}
|
|
try:
|
|
exit_code = int(metadata.get("exit_code"))
|
|
except (TypeError, ValueError):
|
|
exit_code = None
|
|
if exit_code != 2:
|
|
return
|
|
|
|
queue = getattr(context, "async_rewake_queue", None)
|
|
hook_name = f"skill:{meta.name}:{event}:asyncRewake"
|
|
message_text = (
|
|
f"Stop hook blocking error from command \"{hook_name}\": "
|
|
f"{output or getattr(result, 'error', None) or command}"
|
|
)
|
|
if queue is not None:
|
|
from openspace.services.conversation.messages import build_agent_injection_message
|
|
|
|
await queue.put(
|
|
build_agent_injection_message(
|
|
from_agent=hook_name,
|
|
content=message_text,
|
|
message_type="task-notification",
|
|
)
|
|
)
|
|
await context.emit_event(
|
|
"hook_async_rewake_queued",
|
|
{
|
|
"hook_name": hook_name,
|
|
"hook_event": event,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"queued": queue is not None,
|
|
"exit_code": exit_code,
|
|
},
|
|
)
|
|
|
|
|
|
def _render_skill_hook_command(
|
|
command: str,
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> str:
|
|
payload = _skill_hook_payload(
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
rendered = command.replace("$ARGUMENTS", shlex.quote(json.dumps(payload, ensure_ascii=False)))
|
|
skill_root = str(getattr(meta.path, "parent", "") or "").strip()
|
|
if skill_root:
|
|
rendered = (
|
|
f"OPENSPACE_SKILL_ROOT={shlex.quote(skill_root)} "
|
|
f"{rendered}"
|
|
)
|
|
return rendered
|
|
|
|
|
|
async def _execute_skill_prompt_hook(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
hook: Mapping[str, Any],
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
llm_client = getattr(context, "llm_client", None)
|
|
if llm_client is None or not hasattr(llm_client, "call_model"):
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
"Skill prompt hook requires an LLM client",
|
|
command=str(hook.get("prompt") or ""),
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
prompt = _render_skill_hook_text(
|
|
str(hook.get("prompt") or ""),
|
|
meta=meta,
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
timeout_seconds = _hook_timeout_seconds(
|
|
hook, default=DEFAULT_PROMPT_HOOK_TIMEOUT_SECONDS
|
|
)
|
|
try:
|
|
response = await asyncio.wait_for(
|
|
llm_client.call_model(
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"You are evaluating an OpenSpace skill hook. "
|
|
"Respond only with JSON: {\"ok\": true} or "
|
|
"{\"ok\": false, \"reason\": \"...\"}."
|
|
),
|
|
},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
tools=[],
|
|
model=str(hook.get("model") or context.model),
|
|
tool_choice="none",
|
|
abort_event=getattr(context, "abort_event", None),
|
|
),
|
|
timeout=timeout_seconds,
|
|
)
|
|
text = extract_text_from_content(
|
|
getattr(response, "assistant_message", {}).get("content", "")
|
|
)
|
|
except TimeoutError:
|
|
return HookResult(outcome="cancelled")
|
|
except Exception as exc:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Skill prompt hook failed: {exc}",
|
|
command=prompt,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
return _hook_result_from_ok_json(
|
|
text,
|
|
hook_label=f"Skill prompt hook `{meta.name}` ({event})",
|
|
command=prompt,
|
|
event=event,
|
|
)
|
|
|
|
|
|
def _hook_timeout_seconds(
|
|
hook: Mapping[str, Any],
|
|
*,
|
|
default: float,
|
|
) -> float:
|
|
raw = hook.get("timeout")
|
|
if raw is None:
|
|
return default
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
if value <= 0:
|
|
return default
|
|
return value
|
|
|
|
|
|
async def _execute_skill_http_hook(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
hook: Mapping[str, Any],
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
url = str(hook.get("url") or "").strip()
|
|
policy_error = await _validate_http_hook_url(url, context)
|
|
if policy_error:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(policy_error, command=url),
|
|
outcome="blocking",
|
|
)
|
|
payload = _skill_hook_payload(
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers = {"Content-Type": "application/json"}
|
|
allowed_env_vars = _effective_http_hook_env_vars(hook, context)
|
|
for key, value in (hook.get("headers") or {}).items():
|
|
headers[str(key)] = _interpolate_hook_header_value(str(value), allowed_env_vars)
|
|
|
|
def _post() -> tuple[int, str]:
|
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
with urllib.request.urlopen(req, timeout=float(hook.get("timeout") or 30)) as resp:
|
|
return int(resp.status), resp.read().decode("utf-8", errors="replace")
|
|
|
|
try:
|
|
status, text = await asyncio.to_thread(_post)
|
|
except urllib.error.HTTPError as exc:
|
|
text = exc.read().decode("utf-8", errors="replace")
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Skill HTTP hook returned {exc.code}: {text}",
|
|
command=url,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
except Exception as exc:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Skill HTTP hook failed: {exc}",
|
|
command=url,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
if status < 200 or status >= 300:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Skill HTTP hook returned {status}: {text}",
|
|
command=url,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
parsed = _hook_result_from_ok_json(
|
|
text,
|
|
hook_label=f"Skill HTTP hook `{meta.name}` ({event})",
|
|
command=url,
|
|
event=event,
|
|
allow_plain_success=True,
|
|
)
|
|
return parsed
|
|
|
|
|
|
async def _execute_skill_agent_hook(
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
hook: Mapping[str, Any],
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
if getattr(context, "llm_client", None) is None:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
"Skill agent hook requires an LLM client",
|
|
command=str(hook.get("prompt") or ""),
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
from openspace.agents.agent_definitions import AgentDefinition, AgentSource
|
|
from openspace.agents.agent_tool import run_agent
|
|
|
|
prompt = _render_skill_hook_text(
|
|
str(hook.get("prompt") or ""),
|
|
meta=meta,
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
)
|
|
agent_def = AgentDefinition(
|
|
agent_type=f"skill-hook-{meta.name}",
|
|
when_to_use=f"Evaluate hook for skill {meta.name}",
|
|
get_system_prompt=(
|
|
"You are evaluating an OpenSpace skill hook. Return only JSON "
|
|
"{\"ok\": true} or {\"ok\": false, \"reason\": \"...\"}."
|
|
),
|
|
source=AgentSource.CUSTOM,
|
|
tools="*",
|
|
model=str(hook.get("model") or meta.model or context.model),
|
|
description=f"Hook agent for {meta.name}",
|
|
max_turns=max(1, int(hook.get("timeout") or 5)),
|
|
)
|
|
try:
|
|
result = await run_agent(
|
|
agent_def=agent_def,
|
|
prompt=prompt,
|
|
filtered_tools=_filter_tools_for_skill(context, meta.allowed_tools),
|
|
allowed_agent_types=getattr(context, "allowed_agent_types", None),
|
|
parent_context=context,
|
|
parent_agent=None,
|
|
grounding_client=None,
|
|
llm_client=context.llm_client,
|
|
resolved_model=str(hook.get("model") or meta.model or context.model),
|
|
task_description=f"Skill hook: {meta.name}",
|
|
)
|
|
except Exception as exc:
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"Skill agent hook failed: {exc}",
|
|
command=prompt,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
text = getattr(result, "text", None)
|
|
if text is None:
|
|
text = "\n".join(
|
|
str(item.get("text") or item.get("content") or "")
|
|
if isinstance(item, Mapping)
|
|
else str(item)
|
|
for item in (getattr(result, "content", []) or [])
|
|
)
|
|
return _hook_result_from_ok_json(
|
|
text,
|
|
hook_label=f"Skill agent hook `{meta.name}` ({event})",
|
|
command=prompt,
|
|
event=event,
|
|
)
|
|
|
|
|
|
def _render_skill_hook_text(
|
|
template: str,
|
|
*,
|
|
meta: "SkillMeta",
|
|
event: str,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> str:
|
|
payload = json.dumps(
|
|
_skill_hook_payload(
|
|
event=event,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input,
|
|
tool_use_id=tool_use_id,
|
|
context=context,
|
|
kwargs=kwargs,
|
|
),
|
|
ensure_ascii=False,
|
|
)
|
|
return template.replace("$ARGUMENTS", payload)
|
|
|
|
|
|
def _skill_hook_payload(
|
|
*,
|
|
event: str,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
tool_use_id: str,
|
|
context: ToolUseContext,
|
|
kwargs: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
transcript_path = (
|
|
getattr(context, "transcript_path", None)
|
|
or getattr(getattr(context, "recording_manager", None), "transcript_path", None)
|
|
or ""
|
|
)
|
|
payload: dict[str, Any] = {
|
|
"session_id": str(getattr(context, "session_id", None) or ""),
|
|
"transcript_path": str(transcript_path),
|
|
"cwd": str(getattr(context, "cwd", "") or ""),
|
|
"hook_event_name": event,
|
|
# Backward-compatible OS field kept for existing hooks/tests.
|
|
"event": event,
|
|
}
|
|
permission_mode = getattr(context, "permission_mode", None)
|
|
if permission_mode:
|
|
payload["permission_mode"] = str(permission_mode)
|
|
agent_id = str(getattr(context, "agent_id", None) or "")
|
|
if agent_id and agent_id not in {"primary", "main"}:
|
|
payload["agent_id"] = str(agent_id)
|
|
agent_type = getattr(context, "agent_type", None)
|
|
if agent_type:
|
|
payload["agent_type"] = str(agent_type)
|
|
|
|
if event in {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionDenied",
|
|
"PermissionRequest",
|
|
}:
|
|
payload["tool_name"] = tool_name
|
|
payload["tool_input"] = _json_safe_hook_value(dict(tool_input))
|
|
if tool_use_id:
|
|
payload["tool_use_id"] = tool_use_id
|
|
|
|
if event == "PostToolUse" and "tool_result" in kwargs:
|
|
payload["tool_response"] = _json_safe_hook_value(kwargs.get("tool_result"))
|
|
elif event == "PostToolUseFailure":
|
|
payload["error"] = str(kwargs.get("error") or "")
|
|
if "is_interrupt" in kwargs:
|
|
payload["is_interrupt"] = bool(kwargs.get("is_interrupt"))
|
|
elif event == "PermissionDenied":
|
|
payload["reason"] = str(kwargs.get("reason") or "")
|
|
elif event == "PermissionRequest" and "permission_suggestions" in kwargs:
|
|
payload["permission_suggestions"] = _json_safe_hook_value(
|
|
kwargs.get("permission_suggestions")
|
|
)
|
|
elif event == "Notification":
|
|
payload["message"] = str(kwargs.get("message") or "")
|
|
title = kwargs.get("title")
|
|
if title is not None:
|
|
payload["title"] = str(title)
|
|
payload["notification_type"] = str(kwargs.get("notification_type") or "info")
|
|
elif event == "UserPromptSubmit":
|
|
payload["prompt"] = str(kwargs.get("prompt") or "")
|
|
elif event == "SessionStart":
|
|
payload["source"] = str(kwargs.get("source") or "")
|
|
model = kwargs.get("model")
|
|
if model is not None:
|
|
payload["model"] = str(model)
|
|
elif event == "SessionEnd":
|
|
payload["reason"] = str(kwargs.get("reason") or "")
|
|
elif event in {"Stop", "SubagentStop"}:
|
|
payload["stop_hook_active"] = bool(kwargs.get("stop_hook_active"))
|
|
last_message = kwargs.get("last_assistant_message")
|
|
if last_message is not None:
|
|
payload["last_assistant_message"] = str(last_message)
|
|
if event == "SubagentStop":
|
|
if kwargs.get("agent_transcript_path") is not None:
|
|
payload["agent_transcript_path"] = str(kwargs.get("agent_transcript_path"))
|
|
if kwargs.get("agent_type") is not None:
|
|
payload["agent_type"] = str(kwargs.get("agent_type"))
|
|
elif event == "StopFailure":
|
|
if "error" in kwargs:
|
|
payload["error"] = _json_safe_hook_value(kwargs.get("error"))
|
|
if kwargs.get("error_details") is not None:
|
|
payload["error_details"] = str(kwargs.get("error_details"))
|
|
if kwargs.get("last_assistant_message") is not None:
|
|
payload["last_assistant_message"] = str(kwargs.get("last_assistant_message"))
|
|
elif event in {"PreCompact", "PostCompact"}:
|
|
compact_data = kwargs.get("compact_data")
|
|
if isinstance(compact_data, Mapping):
|
|
trigger = compact_data.get("trigger")
|
|
if trigger is not None:
|
|
payload["trigger"] = str(trigger)
|
|
if "custom_instructions" in compact_data:
|
|
payload["custom_instructions"] = compact_data.get("custom_instructions")
|
|
|
|
if kwargs:
|
|
payload["hook"] = {
|
|
key: _json_safe_hook_value(value)
|
|
for key, value in kwargs.items()
|
|
if key not in {"context"}
|
|
}
|
|
return payload
|
|
|
|
|
|
def _json_safe_hook_value(value: Any) -> Any:
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
if isinstance(value, Mapping):
|
|
return {str(key): _json_safe_hook_value(val) for key, val in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [_json_safe_hook_value(item) for item in value]
|
|
if hasattr(value, "status") or hasattr(value, "content") or hasattr(value, "error"):
|
|
data: dict[str, Any] = {}
|
|
for attr in ("status", "content", "error", "metadata"):
|
|
if hasattr(value, attr):
|
|
raw = getattr(value, attr)
|
|
if attr == "status" and hasattr(raw, "value"):
|
|
raw = raw.value
|
|
data[attr] = _json_safe_hook_value(raw)
|
|
return data
|
|
return str(value)
|
|
|
|
|
|
def _hook_result_from_ok_json(
|
|
text: str,
|
|
*,
|
|
hook_label: str,
|
|
command: str,
|
|
event: str = "",
|
|
allow_plain_success: bool = False,
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
cleaned = _strip_json_fence(str(text or "").strip())
|
|
data: Any = None
|
|
if cleaned:
|
|
try:
|
|
data = json.loads(cleaned)
|
|
except Exception:
|
|
data = None
|
|
if isinstance(data, Mapping) and "ok" in data:
|
|
if bool(data.get("ok")):
|
|
return HookResult(system_message=f"{hook_label} passed.")
|
|
reason = str(data.get("reason") or "condition was not met")
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(reason, command=command),
|
|
prevent_continuation=True,
|
|
stop_reason=reason,
|
|
outcome="blocking",
|
|
)
|
|
if isinstance(data, Mapping):
|
|
return _hook_result_from_hook_json(
|
|
data,
|
|
hook_label=hook_label,
|
|
command=command,
|
|
event=event,
|
|
)
|
|
if allow_plain_success:
|
|
return HookResult(
|
|
system_message=f"{hook_label} completed." + (f"\n{text}" if text else "")
|
|
)
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(
|
|
f"{hook_label} did not return valid hook JSON",
|
|
command=command,
|
|
),
|
|
outcome="blocking",
|
|
)
|
|
|
|
|
|
def _hook_result_from_hook_json(
|
|
data: Mapping[str, Any],
|
|
*,
|
|
hook_label: str,
|
|
command: str,
|
|
event: str,
|
|
):
|
|
from openspace.services.tooling.hooks import HookBlockingError, HookResult
|
|
|
|
if data.get("async") is True:
|
|
return HookResult(
|
|
system_message=f"{hook_label} returned async hook output."
|
|
)
|
|
|
|
kwargs: dict[str, Any] = {}
|
|
recognized_keys = {
|
|
"async",
|
|
"continue",
|
|
"stopReason",
|
|
"systemMessage",
|
|
"decision",
|
|
"reason",
|
|
"hookSpecificOutput",
|
|
}
|
|
saw_supported_field = any(key in data for key in recognized_keys)
|
|
if data.get("continue") is False:
|
|
reason = str(data.get("stopReason") or "Execution stopped by hook")
|
|
kwargs["prevent_continuation"] = True
|
|
kwargs["stop_reason"] = reason
|
|
|
|
if data.get("systemMessage"):
|
|
kwargs["system_message"] = str(data.get("systemMessage"))
|
|
|
|
decision = data.get("decision")
|
|
if decision == "approve":
|
|
kwargs["permission_behavior"] = "allow"
|
|
elif decision == "block":
|
|
reason = str(data.get("reason") or "Blocked by hook")
|
|
kwargs["permission_behavior"] = "deny"
|
|
kwargs["hook_permission_decision_reason"] = reason
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
elif decision not in (None, ""):
|
|
reason = f"Unknown hook decision type: {decision}"
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
|
|
hook_specific = data.get("hookSpecificOutput")
|
|
if isinstance(hook_specific, Mapping):
|
|
hook_event_name = str(hook_specific.get("hookEventName") or "")
|
|
if event and hook_event_name and hook_event_name != event:
|
|
reason = (
|
|
f"Hook returned incorrect event name: expected {event}, "
|
|
f"got {hook_event_name}"
|
|
)
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
return HookResult(**kwargs)
|
|
|
|
permission_decision = hook_specific.get("permissionDecision")
|
|
permission_reason = hook_specific.get("permissionDecisionReason")
|
|
if (
|
|
hook_event_name == "PreToolUse"
|
|
and permission_decision in {"allow", "ask", "deny"}
|
|
):
|
|
kwargs["permission_behavior"] = permission_decision
|
|
if permission_reason:
|
|
kwargs["hook_permission_decision_reason"] = str(permission_reason)
|
|
if permission_decision == "deny":
|
|
reason = str(
|
|
permission_reason or data.get("reason") or "Blocked by hook"
|
|
)
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
elif (
|
|
hook_event_name == "PreToolUse"
|
|
and permission_decision not in (None, "")
|
|
):
|
|
reason = f"Unknown hook permissionDecision type: {permission_decision}"
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
|
|
additional_context = hook_specific.get("additionalContext")
|
|
additional_context_events = {
|
|
"PreToolUse",
|
|
"UserPromptSubmit",
|
|
"SessionStart",
|
|
"Setup",
|
|
"SubagentStart",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"Notification",
|
|
}
|
|
if (
|
|
hook_event_name in additional_context_events
|
|
and isinstance(additional_context, str)
|
|
and additional_context
|
|
):
|
|
kwargs["additional_context"] = additional_context
|
|
|
|
if hook_event_name == "PreToolUse":
|
|
updated_input = hook_specific.get("updatedInput")
|
|
if isinstance(updated_input, Mapping):
|
|
kwargs["updated_input"] = dict(updated_input)
|
|
|
|
if hook_event_name == "SessionStart":
|
|
initial_user_message = hook_specific.get("initialUserMessage")
|
|
if isinstance(initial_user_message, str) and initial_user_message:
|
|
kwargs["initial_user_message"] = initial_user_message
|
|
|
|
if hook_event_name in {"SessionStart", "CwdChanged", "FileChanged"}:
|
|
watch_paths = hook_specific.get("watchPaths")
|
|
if isinstance(watch_paths, list) and all(
|
|
isinstance(path, str) for path in watch_paths
|
|
):
|
|
kwargs["watch_paths"] = list(watch_paths)
|
|
|
|
if (
|
|
hook_event_name == "PostToolUse"
|
|
and hook_specific.get("updatedMCPToolOutput")
|
|
):
|
|
kwargs["updated_tool_output"] = hook_specific.get("updatedMCPToolOutput")
|
|
|
|
if hook_event_name == "PermissionDenied" and "retry" in hook_specific:
|
|
kwargs["retry"] = bool(hook_specific.get("retry"))
|
|
|
|
permission_request = hook_specific.get("decision")
|
|
if (
|
|
hook_event_name == "PermissionRequest"
|
|
and isinstance(permission_request, Mapping)
|
|
):
|
|
behavior = permission_request.get("behavior")
|
|
if behavior == "allow":
|
|
kwargs["permission_behavior"] = "allow"
|
|
updated = permission_request.get("updatedInput")
|
|
if isinstance(updated, Mapping):
|
|
kwargs["updated_input"] = dict(updated)
|
|
updated_permissions = permission_request.get("updatedPermissions")
|
|
if isinstance(updated_permissions, list):
|
|
kwargs["updated_permissions"] = list(updated_permissions)
|
|
elif behavior == "deny":
|
|
reason = str(
|
|
permission_request.get("message")
|
|
or data.get("reason")
|
|
or "Denied by hook"
|
|
)
|
|
kwargs["permission_behavior"] = "deny"
|
|
kwargs["hook_permission_decision_reason"] = reason
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
if permission_request.get("interrupt") is True:
|
|
kwargs["prevent_continuation"] = True
|
|
kwargs["stop_reason"] = reason
|
|
|
|
if hook_event_name == "Elicitation" and hook_specific.get("action"):
|
|
action = str(hook_specific.get("action"))
|
|
kwargs["elicitation_response"] = {
|
|
"action": action,
|
|
"content": hook_specific.get("content"),
|
|
}
|
|
if action == "decline":
|
|
reason = str(data.get("reason") or "Elicitation denied by hook")
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
|
|
if hook_event_name == "ElicitationResult" and hook_specific.get("action"):
|
|
action = str(hook_specific.get("action"))
|
|
kwargs["elicitation_result_response"] = {
|
|
"action": action,
|
|
"content": hook_specific.get("content"),
|
|
}
|
|
if action == "decline":
|
|
reason = str(
|
|
data.get("reason") or "Elicitation result blocked by hook"
|
|
)
|
|
kwargs["blocking_error"] = HookBlockingError(reason, command=command)
|
|
kwargs["outcome"] = "blocking"
|
|
|
|
if kwargs:
|
|
return HookResult(**kwargs)
|
|
if not saw_supported_field:
|
|
reason = (
|
|
f"{hook_label} returned unsupported hook JSON fields: "
|
|
+ ", ".join(sorted(str(key) for key in data.keys()))
|
|
)
|
|
return HookResult(
|
|
blocking_error=HookBlockingError(reason, command=command),
|
|
outcome="blocking",
|
|
)
|
|
return HookResult(system_message=f"{hook_label} completed.")
|
|
|
|
|
|
def _looks_like_hook_json(text: str) -> bool:
|
|
cleaned = _strip_json_fence(str(text or "").strip())
|
|
return cleaned.startswith("{") and cleaned.endswith("}")
|
|
|
|
|
|
def _strip_json_fence(text: str) -> str:
|
|
stripped = text.strip()
|
|
if stripped.startswith("```"):
|
|
stripped = re.sub(r"^```(?:json)?\s*", "", stripped)
|
|
stripped = re.sub(r"\s*```$", "", stripped)
|
|
return stripped.strip()
|
|
|
|
|
|
def _interpolate_hook_header_value(value: str, allowed_env_vars: set[str]) -> str:
|
|
def _replace(match: re.Match[str]) -> str:
|
|
name = match.group(1) or match.group(2) or ""
|
|
if name not in allowed_env_vars:
|
|
return ""
|
|
return os.environ.get(name, "")
|
|
|
|
return re.sub(
|
|
r"\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)",
|
|
_replace,
|
|
value,
|
|
).replace("\r", "").replace("\n", "").replace("\x00", "")
|
|
|
|
|
|
def _effective_http_hook_env_vars(
|
|
hook: Mapping[str, Any],
|
|
context: ToolUseContext,
|
|
) -> set[str]:
|
|
hook_vars = {str(item) for item in (hook.get("allowedEnvVars") or [])}
|
|
policy_vars = getattr(context, "http_hook_allowed_env_vars", None)
|
|
if policy_vars is None:
|
|
raw_policy = os.environ.get("OPENSPACE_HTTP_HOOK_ALLOWED_ENV_VARS")
|
|
if raw_policy is not None:
|
|
policy_vars = [item.strip() for item in raw_policy.split(",")]
|
|
if policy_vars is None:
|
|
return set()
|
|
allowed = {str(item) for item in policy_vars if str(item)}
|
|
return hook_vars.intersection(allowed)
|
|
|
|
|
|
async def _validate_http_hook_url(url: str, context: ToolUseContext) -> str | None:
|
|
parsed = urllib.parse.urlparse(url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
return f"HTTP hook blocked: unsupported URL {url!r}"
|
|
|
|
allowed_urls = _http_hook_allowed_urls(context)
|
|
if not allowed_urls:
|
|
return (
|
|
"HTTP hook blocked: no allowed HTTP hook URL patterns configured "
|
|
"(set context.http_hook_allowed_urls or OPENSPACE_ALLOWED_HTTP_HOOK_URLS)"
|
|
)
|
|
if not any(
|
|
_url_matches_pattern(url, pattern) for pattern in allowed_urls
|
|
):
|
|
return (
|
|
f"HTTP hook blocked: {url} does not match any configured "
|
|
"allowed HTTP hook URL pattern"
|
|
)
|
|
|
|
return await asyncio.to_thread(_ssrf_guard_http_hook_url, parsed)
|
|
|
|
|
|
def _http_hook_allowed_urls(context: ToolUseContext) -> list[str] | None:
|
|
configured = getattr(context, "http_hook_allowed_urls", None)
|
|
if configured is not None:
|
|
return [str(item) for item in configured]
|
|
raw = os.environ.get("OPENSPACE_ALLOWED_HTTP_HOOK_URLS")
|
|
if raw is None:
|
|
return None
|
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
|
|
|
|
def _url_matches_pattern(url: str, pattern: str) -> bool:
|
|
try:
|
|
import fnmatch
|
|
|
|
return fnmatch.fnmatch(url, pattern)
|
|
except Exception:
|
|
return url == pattern
|
|
|
|
|
|
def _ssrf_guard_http_hook_url(parsed: urllib.parse.ParseResult) -> str | None:
|
|
host = parsed.hostname
|
|
if not host:
|
|
return "HTTP hook blocked: URL has no host"
|
|
try:
|
|
ips = [ipaddress.ip_address(host)]
|
|
except ValueError:
|
|
try:
|
|
infos = socket.getaddrinfo(host, parsed.port or _default_url_port(parsed))
|
|
except Exception as exc:
|
|
return f"HTTP hook blocked: could not resolve {host}: {exc}"
|
|
ips = []
|
|
for info in infos:
|
|
try:
|
|
ips.append(ipaddress.ip_address(info[4][0]))
|
|
except Exception:
|
|
continue
|
|
for ip in ips:
|
|
if ip.is_loopback:
|
|
continue
|
|
if (
|
|
ip.is_private
|
|
or ip.is_link_local
|
|
or ip.is_multicast
|
|
or ip.is_reserved
|
|
or ip.is_unspecified
|
|
):
|
|
return f"HTTP hook blocked: {host} resolves to restricted address {ip}"
|
|
return None
|
|
|
|
|
|
def _default_url_port(parsed: urllib.parse.ParseResult) -> int:
|
|
return 443 if parsed.scheme == "https" else 80
|
|
|
|
|
|
_TOOL_MATCH_ALIASES: dict[str, tuple[str, ...]] = {
|
|
"bash": ("Bash",),
|
|
"powershell": ("PowerShell",),
|
|
"read": ("Read",),
|
|
"edit": ("Edit",),
|
|
"write": ("Write",),
|
|
"grep": ("Grep",),
|
|
"glob": ("Glob",),
|
|
"ls": ("LS",),
|
|
"web_search": ("WebSearch",),
|
|
"web_fetch": ("WebFetch",),
|
|
"ask_user_question": ("AskUserQuestion",),
|
|
"task": ("Task",),
|
|
"todo_write": ("TodoWrite",),
|
|
"notebook_edit": ("NotebookEdit",),
|
|
}
|
|
|
|
|
|
def _skill_hook_matcher_matches(
|
|
matcher: Any,
|
|
*,
|
|
event: str,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
kwargs: Mapping[str, Any],
|
|
) -> bool:
|
|
raw = str(matcher or "").strip()
|
|
if not raw or raw == "*":
|
|
return True
|
|
|
|
actual_tool = normalize_tool_name_for_rule(tool_name)
|
|
if event in {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionDenied",
|
|
"PermissionRequest",
|
|
}:
|
|
try:
|
|
rule = parse_rule_value(raw)
|
|
except Exception:
|
|
rule = None
|
|
if rule is not None and "(" in raw and raw.endswith(")"):
|
|
if rule.tool_name != actual_tool:
|
|
return False
|
|
return _skill_hook_rule_content_matches(
|
|
rule.tool_name,
|
|
rule.rule_content,
|
|
tool_input,
|
|
)
|
|
|
|
match_query = _skill_hook_match_query(event, tool_name, kwargs)
|
|
if match_query is None:
|
|
# Events without a match query match all configured hooks.
|
|
return True
|
|
|
|
candidates = _skill_hook_match_candidates(match_query)
|
|
if re.fullmatch(r"[A-Za-z0-9_|_]+", raw):
|
|
expected = [normalize_tool_name_for_rule(part.strip()) for part in raw.split("|")]
|
|
return any(candidate in expected for candidate in candidates)
|
|
|
|
try:
|
|
pattern = re.compile(raw)
|
|
except re.error:
|
|
logger.debug("Invalid skill hook matcher regex: %r", raw)
|
|
return False
|
|
return any(pattern.search(candidate) for candidate in candidates)
|
|
|
|
|
|
def _skill_hook_match_query(
|
|
event: str,
|
|
tool_name: str,
|
|
kwargs: Mapping[str, Any],
|
|
) -> str | None:
|
|
if event in {
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PostToolUseFailure",
|
|
"PermissionDenied",
|
|
"PermissionRequest",
|
|
}:
|
|
return normalize_tool_name_for_rule(tool_name)
|
|
if event == "SessionStart":
|
|
return str(kwargs.get("source") or "")
|
|
if event in {"PreCompact", "PostCompact"}:
|
|
compact_data = kwargs.get("compact_data")
|
|
if isinstance(compact_data, Mapping):
|
|
return str(compact_data.get("trigger") or "")
|
|
return str(kwargs.get("trigger") or "")
|
|
if event == "Notification":
|
|
return str(kwargs.get("notification_type") or "")
|
|
if event == "SessionEnd":
|
|
return str(kwargs.get("reason") or "")
|
|
if event == "StopFailure":
|
|
return str(kwargs.get("error") or "")
|
|
if event in {"SubagentStart", "SubagentStop"}:
|
|
return str(kwargs.get("agent_type") or "")
|
|
return None
|
|
|
|
|
|
def _skill_hook_match_candidates(match_query: str) -> list[str]:
|
|
normalized = normalize_tool_name_for_rule(match_query)
|
|
candidates = [normalized, match_query]
|
|
candidates.extend(_TOOL_MATCH_ALIASES.get(normalized, ()))
|
|
seen: set[str] = set()
|
|
deduped: list[str] = []
|
|
for candidate in candidates:
|
|
if candidate and candidate not in seen:
|
|
seen.add(candidate)
|
|
deduped.append(candidate)
|
|
return deduped
|
|
|
|
|
|
def _skill_hook_condition_matches(
|
|
condition: Any,
|
|
tool_name: str,
|
|
tool_input: Mapping[str, Any],
|
|
) -> bool:
|
|
raw = str(condition or "").strip()
|
|
if not raw:
|
|
return True
|
|
try:
|
|
expected_tool = parse_rule_value(raw).tool_name
|
|
except Exception:
|
|
expected_tool = None
|
|
actual_tool = normalize_tool_name_for_rule(tool_name)
|
|
if expected_tool and expected_tool != actual_tool:
|
|
return False
|
|
try:
|
|
rule = parse_rule_value(raw)
|
|
except Exception:
|
|
rule = None
|
|
if rule is not None and "(" in raw and raw.endswith(")"):
|
|
if rule.tool_name != actual_tool:
|
|
return False
|
|
return _skill_hook_rule_content_matches(
|
|
rule.tool_name,
|
|
rule.rule_content,
|
|
tool_input,
|
|
)
|
|
if "(" not in raw or not raw.endswith(")"):
|
|
return True
|
|
pattern = raw.split("(", 1)[1][:-1].strip()
|
|
if not pattern or pattern == "*":
|
|
return True
|
|
haystack = json.dumps(dict(tool_input), ensure_ascii=False)
|
|
try:
|
|
import fnmatch
|
|
|
|
return fnmatch.fnmatch(haystack, pattern) or pattern in haystack
|
|
except Exception:
|
|
return pattern in haystack
|
|
|
|
|
|
def _skill_hook_rule_content_matches(
|
|
tool_name: str,
|
|
rule_content: str | None,
|
|
tool_input: Mapping[str, Any],
|
|
) -> bool:
|
|
if rule_content in (None, "", "*"):
|
|
return True
|
|
pattern = str(rule_content)
|
|
candidates: list[str] = []
|
|
if tool_name == "bash":
|
|
candidates.append(str(tool_input.get("command") or ""))
|
|
for key in ("file_path", "path", "notebook_path", "url"):
|
|
if tool_input.get(key) is not None:
|
|
candidates.append(str(tool_input.get(key)))
|
|
if not candidates:
|
|
candidates.append(json.dumps(dict(tool_input), ensure_ascii=False))
|
|
try:
|
|
import fnmatch
|
|
|
|
return any(
|
|
candidate == pattern
|
|
or fnmatch.fnmatch(candidate, pattern)
|
|
or pattern in candidate
|
|
for candidate in candidates
|
|
)
|
|
except Exception:
|
|
return any(candidate == pattern or pattern in candidate for candidate in candidates)
|
|
|
|
|
|
async def _execute_skill_prompt_shell_expansions(
|
|
content: str,
|
|
*,
|
|
meta: "SkillMeta",
|
|
context: ToolUseContext | None,
|
|
) -> str:
|
|
"""Execute OpenSpace prompt-shell blocks in loaded skill markdown.
|
|
|
|
OpenSpace supports inline ``!`cmd` `` and fenced `````!`` blocks in skill markdown
|
|
for local prompt generation. OS mirrors the behavior only for local skills;
|
|
MCP/remote skills are left untouched because their markdown is not trusted
|
|
to execute local commands.
|
|
"""
|
|
|
|
if context is None:
|
|
return content
|
|
if meta.loaded_from == "mcp":
|
|
return content
|
|
if "```!" not in content and "!`" not in content:
|
|
return content
|
|
|
|
matches: list[tuple[int, int, str, str]] = []
|
|
for match in PROMPT_SHELL_BLOCK_RE.finditer(content):
|
|
command = (match.group(1) or "").strip()
|
|
if command:
|
|
matches.append((match.start(), match.end(), match.group(0), command))
|
|
if "!`" in content:
|
|
for match in PROMPT_SHELL_INLINE_RE.finditer(content):
|
|
command = (match.group(2) or "").strip()
|
|
if command:
|
|
replacement_target = match.group(0)
|
|
matches.append((
|
|
match.start(),
|
|
match.end(),
|
|
replacement_target,
|
|
command,
|
|
))
|
|
|
|
if not matches:
|
|
return content
|
|
|
|
shell_tool = _find_skill_prompt_shell_tool(context, meta.shell)
|
|
if shell_tool is None:
|
|
raise RuntimeError("skill prompt shell expansion requires the bash tool")
|
|
|
|
old_permission_context = context.permission_context
|
|
_append_skill_allowed_tools(context, meta.allowed_tools)
|
|
try:
|
|
from openspace.tool_runtime.pipeline.execution import (
|
|
run_tool_use,
|
|
tool_call_result_to_tool_result,
|
|
)
|
|
|
|
shell_tool_name = shell_tool.schema.name
|
|
shell_tool_map = {shell_tool_name: shell_tool}
|
|
replacements: list[tuple[int, int, str]] = []
|
|
for start, end, matched_text, command in matches:
|
|
tool_call = {
|
|
"id": f"skill-prompt-shell-{len(replacements)}-{start}",
|
|
"type": "function",
|
|
"function": {
|
|
"name": shell_tool_name,
|
|
"arguments": {
|
|
"command": command,
|
|
"description": f"Skill prompt expansion: {meta.name}",
|
|
},
|
|
},
|
|
}
|
|
result = tool_call_result_to_tool_result(
|
|
await run_tool_use(tool_call, shell_tool_map, context)
|
|
)
|
|
if result.status != ToolStatus.SUCCESS:
|
|
error = result.error or extract_text_from_content(result.content)
|
|
raise RuntimeError(
|
|
f"Shell command failed for pattern {matched_text!r}: {error}"
|
|
)
|
|
output = extract_text_from_content(result.content).strip()
|
|
if start < end and matched_text.startswith((" !`", "\t!`", "\n!`")):
|
|
output = matched_text[0] + output
|
|
replacements.append((start, end, output))
|
|
finally:
|
|
context.permission_context = old_permission_context
|
|
|
|
updated = content
|
|
for start, end, output in sorted(replacements, reverse=True):
|
|
updated = updated[:start] + output + updated[end:]
|
|
return updated
|
|
|
|
|
|
def _find_skill_prompt_shell_tool(
|
|
context: ToolUseContext,
|
|
shell: str | None,
|
|
) -> Any | None:
|
|
candidates = list(getattr(context, "all_tools", None) or []) + list(
|
|
getattr(context, "tools", None) or []
|
|
)
|
|
preferred = "powershell" if shell == "powershell" else "bash"
|
|
for name in (preferred, "bash"):
|
|
for tool in candidates:
|
|
tool_name = str(getattr(tool, "name", "") or "")
|
|
aliases = {str(alias) for alias in (getattr(tool, "aliases", []) or [])}
|
|
if tool_name == name or name in aliases:
|
|
return tool
|
|
return None
|
|
|
|
|
|
def _filter_tools_for_skill(context: ToolUseContext, allowed_tools: Sequence[str]) -> list[Any]:
|
|
tools = list(getattr(context, "all_tools", None) or getattr(context, "tools", []))
|
|
if not allowed_tools:
|
|
return tools
|
|
allowed_names = {
|
|
_allowed_tool_name_for_filter(item)
|
|
for item in allowed_tools
|
|
if str(item).strip()
|
|
}
|
|
allowed_names.discard("")
|
|
if not allowed_names:
|
|
return tools
|
|
return [
|
|
tool
|
|
for tool in tools
|
|
if getattr(tool, "name", "") in allowed_names
|
|
or allowed_names.intersection(set(getattr(tool, "aliases", []) or ()))
|
|
]
|
|
|
|
|
|
def _allowed_tool_name_for_filter(raw: Any) -> str:
|
|
value = str(raw or "").strip()
|
|
if not value:
|
|
return ""
|
|
try:
|
|
return parse_rule_value(value).tool_name
|
|
except Exception:
|
|
return normalize_tool_name_for_rule(value.split("(", 1)[0].strip())
|
|
|
|
|
|
async def _execute_forked_skill(
|
|
*,
|
|
meta: "SkillMeta",
|
|
content: str,
|
|
args: str,
|
|
context: ToolUseContext | None,
|
|
context_modifier,
|
|
) -> ToolResult:
|
|
if context is None or getattr(context, "llm_client", None) is None:
|
|
result = ToolResult(
|
|
status=ToolStatus.ERROR,
|
|
content="Error: forked skill requires an active ToolUseContext and LLM client.",
|
|
error="missing ToolUseContext or LLM client",
|
|
metadata={
|
|
"tool": SKILL_TOOL_NAME,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"execution_context": "fork",
|
|
},
|
|
)
|
|
return result
|
|
|
|
from openspace.agents.agent_definitions import AgentDefinition, AgentSource
|
|
from openspace.agents.agent_tool import run_agent
|
|
|
|
agent_type = meta.agent or "general-purpose"
|
|
agent_def = _resolve_skill_agent_definition(context, agent_type, meta)
|
|
if agent_def is None:
|
|
agent_def = AgentDefinition(
|
|
agent_type=agent_type,
|
|
when_to_use=f"Execute skill {meta.name}",
|
|
get_system_prompt=(
|
|
"You are executing a single OpenSpace skill in an isolated "
|
|
"forked context. Follow the skill instructions exactly and "
|
|
"return a concise result for the parent agent."
|
|
),
|
|
source=AgentSource.CUSTOM,
|
|
tools="*",
|
|
model=meta.model,
|
|
effort=meta.effort,
|
|
description=f"Forked skill runner for {meta.name}",
|
|
)
|
|
|
|
result = await run_agent(
|
|
agent_def=agent_def,
|
|
prompt=content,
|
|
filtered_tools=_filter_tools_for_skill(context, meta.allowed_tools),
|
|
allowed_agent_types=getattr(context, "allowed_agent_types", None),
|
|
parent_context=context,
|
|
parent_agent=None,
|
|
grounding_client=None,
|
|
llm_client=context.llm_client,
|
|
resolved_model=meta.model or context.model,
|
|
task_description=f"Skill: {meta.name}",
|
|
child_context_modifier=context_modifier,
|
|
)
|
|
text = getattr(result, "text", None)
|
|
if text is None:
|
|
content_items = getattr(result, "content", []) or []
|
|
text_parts = []
|
|
for item in content_items:
|
|
if isinstance(item, Mapping):
|
|
text_parts.append(str(item.get("text") or item.get("content") or ""))
|
|
else:
|
|
text_parts.append(str(item))
|
|
text = "\n".join(part for part in text_parts if part).strip()
|
|
tool_result = ToolResult(
|
|
status=ToolStatus.SUCCESS if getattr(result, "status", "") == "completed" else ToolStatus.ERROR,
|
|
content=(
|
|
f"Skill \"{meta.name}\" completed (forked execution).\n\n"
|
|
f"Result:\n{text or getattr(result, 'status', 'unknown')}"
|
|
),
|
|
error=None if getattr(result, "status", "") == "completed" else text,
|
|
metadata={
|
|
"tool": SKILL_TOOL_NAME,
|
|
"skill_id": meta.skill_id,
|
|
"skill_name": meta.name,
|
|
"execution_context": "fork",
|
|
"agent_id": getattr(result, "agent_id", None),
|
|
"agent_type": getattr(result, "agent_type", agent_type),
|
|
},
|
|
)
|
|
attachment = create_attachment_message(
|
|
{
|
|
"type": "invoked_skill_content",
|
|
"name": meta.name,
|
|
"skill_id": meta.skill_id,
|
|
"path": str(meta.path),
|
|
"content": content,
|
|
"agent_id": str(getattr(result, "agent_id", None) or ""),
|
|
"allowed_tools": list(meta.allowed_tools),
|
|
"model": meta.model,
|
|
"effort": meta.effort,
|
|
"execution_context": "fork",
|
|
}
|
|
)
|
|
setattr(tool_result, "additional_messages", [attachment])
|
|
return tool_result
|
|
|
|
|
|
def _resolve_skill_agent_definition(
|
|
context: ToolUseContext,
|
|
agent_type: str,
|
|
meta: "SkillMeta",
|
|
):
|
|
agent_result = getattr(context, "agent_definitions", None)
|
|
active = getattr(agent_result, "active_agents", None)
|
|
if active:
|
|
for agent in active:
|
|
if getattr(agent, "agent_type", None) == agent_type:
|
|
if meta.model or meta.effort:
|
|
from dataclasses import replace
|
|
|
|
updates = {}
|
|
if meta.model:
|
|
updates["model"] = meta.model
|
|
if meta.effort:
|
|
updates["effort"] = meta.effort
|
|
return replace(agent, **updates)
|
|
return agent
|
|
return None
|
|
|
|
|
|
_WORD_RE = re.compile(r"[a-z0-9]+")
|
|
|
|
|
|
def _tokenize(text: str) -> set[str]:
|
|
return set(_WORD_RE.findall(text.lower()))
|
|
|
|
|
|
def _score_skill(
|
|
query_tokens: set[str],
|
|
name: str,
|
|
description: str,
|
|
when_to_use: str | None,
|
|
body: str,
|
|
) -> float:
|
|
if not query_tokens:
|
|
return 0.0
|
|
name_tokens = _tokenize(name)
|
|
desc_tokens = _tokenize(description)
|
|
when_tokens = _tokenize(when_to_use or "")
|
|
body_tokens = _tokenize(body[:4000])
|
|
score = 0.0
|
|
score += 4.0 * len(query_tokens & name_tokens)
|
|
score += 2.0 * len(query_tokens & desc_tokens)
|
|
score += 2.0 * len(query_tokens & when_tokens)
|
|
score += 0.5 * len(query_tokens & body_tokens)
|
|
phrase = " ".join(sorted(query_tokens))
|
|
haystack = f"{name} {description} {when_to_use or ''}".lower()
|
|
if phrase and phrase in haystack:
|
|
score += 2.0
|
|
return score
|
|
|
|
|
|
__all__ = [
|
|
"DISCOVER_SKILLS_TOOL_NAME",
|
|
"DiscoverSkillsTool",
|
|
"SKILL_TOOL_NAME",
|
|
"SkillDiscoveryHit",
|
|
"SkillDiscoveryService",
|
|
"SkillListingService",
|
|
"SkillTool",
|
|
"build_skill_discovery_messages",
|
|
"build_skill_discovery_messages_async",
|
|
"build_skill_listing_messages",
|
|
"consume_dynamic_skill_triggers",
|
|
"filter_to_bundled_and_mcp",
|
|
"format_skills_within_budget",
|
|
"has_skill_tool",
|
|
"restore_skill_state_from_messages",
|
|
]
|