"""Skill-context listing, discovery, and attachment helpers.""" from __future__ import annotations import asyncio import html import json import re from typing import Any, Iterable from openspace.services.conversation.attachments import create_attachment_message from openspace.services.tooling.context import ToolUseContext from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) def bind_skill_tools_to_context( tools: Iterable[Any], tool_use_context: ToolUseContext, ) -> None: if getattr(tool_use_context, "skills_disabled", False): return from openspace.skill_engine.protocol import ( DISCOVER_SKILLS_TOOL_NAME, SKILL_TOOL_NAME, tool_matches_name as skill_tool_matches_name, ) for tool in tools: if not ( skill_tool_matches_name(tool, SKILL_TOOL_NAME) or skill_tool_matches_name(tool, DISCOVER_SKILLS_TOOL_NAME) ): continue setter = getattr(tool, "set_context", None) if callable(setter): setter(tool_use_context) def append_skill_listing_delta( agent: Any, messages: list[dict[str, Any]], tool_use_context: ToolUseContext, ) -> None: if getattr(tool_use_context, "skills_disabled", False): return registry = getattr(agent, "_skill_registry", None) if not registry: return if not getattr(agent, "_skill_listing_enabled", True): return from openspace.skill_engine.protocol import build_skill_listing_messages listing_messages = build_skill_listing_messages( tool_use_context, registry=registry, tools=tool_use_context.tools, discovery_enabled=getattr(agent, "_skill_discovery_enabled", True), store=getattr(agent, "_skill_store", None), listing_budget_context_percent=getattr( agent, "_skill_listing_budget_context_percent", 0.01, ), listing_max_description_chars=getattr( agent, "_skill_listing_max_description_chars", 250, ), ) if not listing_messages: return messages.extend(listing_messages) tool_use_context.replace_messages(messages) def append_skill_discovery_delta( agent: Any, messages: list[dict[str, Any]], tool_use_context: ToolUseContext, *, query: str, source: str, ) -> None: if getattr(tool_use_context, "skills_disabled", False): return registry = getattr(agent, "_skill_registry", None) if not registry or not getattr(agent, "_skill_discovery_enabled", True): return if not str(query or "").strip(): return if not has_skill_tool(tool_use_context.tools): return from openspace.skill_engine.protocol import build_skill_discovery_messages discovery_messages = build_skill_discovery_messages( tool_use_context, registry=registry, query=query, max_results=getattr(agent, "_skill_discovery_max_results", 5), store=getattr(agent, "_skill_store", None), source=source, ) if not discovery_messages: return messages.extend(discovery_messages) tool_use_context.replace_messages(messages) async def append_skill_discovery_delta_async( agent: Any, messages: list[dict[str, Any]], tool_use_context: ToolUseContext, *, query: str, source: str, ) -> None: if getattr(tool_use_context, "skills_disabled", False): return registry = getattr(agent, "_skill_registry", None) if not registry or not getattr(agent, "_skill_discovery_enabled", True): return if not str(query or "").strip(): return if not has_skill_tool(tool_use_context.tools): return if source == "turn0_prefetch" and not getattr( agent, "_enable_turn0_llm_skill_selector", True, ): append_skill_discovery_delta( agent, messages, tool_use_context, query=query, source=source, ) return from openspace.skill_engine.protocol import build_skill_discovery_messages_async discovery_messages = await build_skill_discovery_messages_async( tool_use_context, registry=registry, query=query, max_results=getattr(agent, "_skill_discovery_max_results", 5), store=getattr(agent, "_skill_store", None), source=source, llm_client=( getattr(agent, "_skill_selection_llm", None) or getattr(agent, "_tool_retrieval_llm", None) or getattr(agent, "_llm_client", None) ), ) if not discovery_messages: return messages.extend(discovery_messages) tool_use_context.replace_messages(messages) def has_skill_tool(tools: Iterable[Any]) -> bool: try: from openspace.skill_engine.protocol import ( SKILL_TOOL_NAME, tool_matches_name as skill_tool_matches_name, ) except Exception: return False return any(skill_tool_matches_name(tool, SKILL_TOOL_NAME) for tool in tools) def has_discover_skills_tool(tools: Iterable[Any]) -> bool: try: from openspace.skill_engine.protocol import ( DISCOVER_SKILLS_TOOL_NAME, tool_matches_name as skill_tool_matches_name, ) except Exception: return False return any( skill_tool_matches_name(tool, DISCOVER_SKILLS_TOOL_NAME) for tool in tools ) def skill_discovery_query_from_recent_messages( instruction: str, messages: list[dict[str, Any]], ) -> str: parts: list[str] = [str(instruction or "").strip()] for message in reversed(messages[-8:]): if message.get("role") not in {"assistant", "tool", "user"}: continue content = message.get("content") if isinstance(content, str): text = content.strip() else: text = json.dumps(content, ensure_ascii=False, default=str) if text: parts.append(text[:800]) if len(parts) >= 5: break return "\n\n".join(part for part in parts if part)[:4000] async def build_post_tool_skill_discovery_query( agent: Any, instruction: str, messages: list[dict[str, Any]], *, abort_event: asyncio.Event | None = None, ) -> str: fallback_query = skill_discovery_query_from_recent_messages( instruction, messages, ) if not getattr(agent, "_post_tool_query_builder_enabled", False): return fallback_query llm_client = ( getattr(agent, "_skill_selection_llm", None) or getattr(agent, "_tool_retrieval_llm", None) or getattr(agent, "_llm_client", None) ) if llm_client is None: return fallback_query max_chars = getattr(agent, "_post_tool_query_builder_max_chars", 4000) evidence = fallback_query[:max_chars] if not evidence.strip(): return fallback_query escaped_evidence = html.escape(evidence, quote=False) prompt = ( "You are writing a search query for retrieving reusable workflow instructions.\n\n" "Given recent task evidence, produce one concise search query. Focus on " "the user's goal, file or domain type, framework and tool names, concrete " "error classes, and the next likely workflow. Do not summarize the " "conversation. Do not include irrelevant logs, assistant narration, " "secrets, full paths, IDs, or large literals.\n\n" "Return XML only:\n" "...\n\n" "Query rules:\n" "- Write one English declarative sentence, 12-40 words.\n" "- Preserve exact technical tokens such as pytest, FastAPI, React, XLSX, ImportError, ffmpeg.\n" "- Prefer reusable workflow terms over project-specific names.\n" "- If evidence is too vague, use the original user goal as the query.\n" "- Do not mention skill, retrieval, or instructions in the query.\n\n" "Evidence:\n" f"\n{escaped_evidence}\n" ) try: kwargs: dict[str, Any] = {"messages": [{"role": "user", "content": prompt}]} model = getattr(agent, "_post_tool_query_builder_model", None) if model: kwargs["model"] = model if abort_event is not None: kwargs["abort_event"] = abort_event response = await llm_client.call_model(**kwargs) content = str(response.assistant_message.get("content", "") or "").strip() query = "" xml_match = re.search( r"\s*(.*?)\s*", content, re.DOTALL | re.IGNORECASE, ) if xml_match: query = html.unescape(xml_match.group(1)).strip() if not query: try: data = json.loads(content) if isinstance(data, dict): query = str(data.get("query") or "").strip() except json.JSONDecodeError: match = re.search(r"\{.*\}", content, re.DOTALL) if match: try: data = json.loads(match.group()) if isinstance(data, dict): query = str(data.get("query") or "").strip() except json.JSONDecodeError: query = "" if not query: query = content.strip() return query[:1000] if query else fallback_query except Exception: logger.debug("Post-tool skill discovery query builder failed", exc_info=True) return fallback_query def append_agent_listing_delta( messages: list[dict[str, Any]], tool_use_context: ToolUseContext, ) -> None: from openspace.services.conversation.attachments import get_agent_listing_delta_attachment attachments = get_agent_listing_delta_attachment(tool_use_context, messages) if not attachments: return messages.extend(create_attachment_message(attachment) for attachment in attachments) tool_use_context.replace_messages(messages) def extract_skill_ids_from_messages(messages: list[dict[str, Any]]) -> list[str]: skill_ids: list[str] = [] for message in messages or []: if not isinstance(message, dict): continue meta = message.get("_meta") if not isinstance(meta, dict): continue attachment = meta.get("attachment") if isinstance(attachment, dict): attachment_type = attachment.get("type") if attachment_type == "invoked_skill_content": skill_id = str(attachment.get("skill_id") or "").strip() if skill_id: skill_ids.append(skill_id) elif attachment_type == "invoked_skills": for item in attachment.get("skills") or []: if isinstance(item, dict): skill_id = str(item.get("skill_id") or "").strip() if skill_id: skill_ids.append(skill_id) result_meta = meta.get("tool_result_metadata") if isinstance(result_meta, dict) and result_meta.get("tool") == "Skill": skill_id = str(result_meta.get("skill_id") or "").strip() if skill_id: skill_ids.append(skill_id) return list(dict.fromkeys(skill_ids)) __all__ = [ "append_agent_listing_delta", "append_skill_discovery_delta", "append_skill_discovery_delta_async", "append_skill_listing_delta", "bind_skill_tools_to_context", "build_post_tool_skill_discovery_query", "extract_skill_ids_from_messages", "has_discover_skills_tool", "has_skill_tool", "skill_discovery_query_from_recent_messages", ]