Spaces:
Running
Running
| """ | |
| ModalitySkillEngine — Load and execute persona-intrinsic modality skills. | |
| Architecture: Claude Skill pattern (prompt-driven, not function calling). | |
| SKILL.md body → injected as LLM instructions | |
| LLM outputs structured JSON → engine parses | |
| Engine calls tools via ToolRegistry | |
| Works with ANY LLM provider — no function calling support required. | |
| Lifecycle: | |
| L1 build_prompt() → inject descriptions into Express prompt | |
| L2 activate() → load SKILL.md body on first use | |
| L3 execute() → prompt LLM → parse JSON → call tools | |
| """ | |
| from __future__ import annotations | |
| import importlib | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Optional, List | |
| import frontmatter | |
| from agent.skills.skill_types import ( | |
| SKILL_FILENAME, | |
| ExecutionStatus, | |
| Skill, | |
| SkillExecutionResult, | |
| load_skill, | |
| ) | |
| from typing import TYPE_CHECKING | |
| if TYPE_CHECKING: | |
| from agent.skills.tool_registry import ToolRegistry | |
| class ModalitySkillEngine: | |
| """Persona-intrinsic skill engine for modality-triggered skills.""" | |
| def __init__(self, skills_dir: str, tool_registry: "Optional[ToolRegistry]" = None): | |
| self.skills_dir = Path(skills_dir) | |
| self.tool_registry = tool_registry | |
| self._skills: dict[str, Skill] = {} | |
| # -- Loading (L1) ------------------------------------------------------- | |
| def load_all(self) -> dict[str, Skill]: | |
| """Load L1 metadata for trigger=modality skills only.""" | |
| self._skills.clear() | |
| if not self.skills_dir.exists(): | |
| return {} | |
| for entry in sorted(self.skills_dir.iterdir()): | |
| if entry.is_dir(): | |
| skill_file = entry / SKILL_FILENAME | |
| if skill_file.exists(): | |
| try: | |
| skill = load_skill(entry) | |
| if skill.trigger == "modality" and skill.modality: | |
| self._skills[skill.skill_id] = skill | |
| except Exception as e: | |
| print(f"[modality-skill] Failed to load {entry.name}: {e}") | |
| return self._skills | |
| # -- L2 activation ------------------------------------------------------- | |
| def activate(self, skill_id: str) -> None: | |
| """Load L2 body (SKILL.md content) for a skill. Idempotent.""" | |
| skill = self._skills.get(skill_id) | |
| if not skill or skill.is_activated: | |
| return | |
| post = frontmatter.load(str(Path(skill.base_dir) / SKILL_FILENAME)) | |
| skill.body = post.content.strip() | |
| # -- Queries -------------------------------------------------------------- | |
| def modality_skills(self) -> dict[str, str]: | |
| return {s.modality: s.skill_id for s in self._skills.values()} | |
| def get_by_modality(self, modality: str) -> Optional[Skill]: | |
| skill_id = self.modality_skills.get(modality) | |
| return self._skills.get(skill_id) if skill_id else None | |
| def build_prompt(self) -> str: | |
| skills = list(self._skills.values()) | |
| if not skills: | |
| return "" | |
| parts = ["# 技能指南"] | |
| for skill in skills: | |
| if skill.description: | |
| parts.append(f"\n## {skill.name}\n{skill.description}") | |
| return "\n".join(parts) | |
| # -- Plan & Execute (L3 — VERA-inspired) ----------------------------------- | |
| async def plan_and_execute( | |
| self, | |
| raw_modality: str, | |
| raw_output: str, | |
| persona, | |
| llm, | |
| chat_history: list = None, | |
| ) -> List[SkillExecutionResult]: | |
| """LLM-driven multi-skill planning and execution. | |
| Inspired by VERA's activate_skill pattern: | |
| 1. LLM sees all available skill summaries + raw_modality | |
| 2. LLM returns an ordered execution plan (JSON array) | |
| 3. Engine executes each skill in order, merging results | |
| Returns list of SkillExecutionResult (one per executed skill). | |
| """ | |
| from providers.llm.base import ChatMessage | |
| if not self._skills: | |
| return [] | |
| # Build skill catalog for the planning prompt | |
| skill_catalog = [] | |
| for skill in self._skills.values(): | |
| entry = { | |
| "modality": skill.modality, | |
| "name": skill.name, | |
| "description": skill.description, | |
| "tools": skill.tools, | |
| } | |
| skill_catalog.append(entry) | |
| catalog_json = json.dumps(skill_catalog, ensure_ascii=False, indent=2) | |
| system_prompt = ( | |
| "你是一个 SKILL 调度器。根据 Express 输出的【表达方式】,决定需要执行哪些技能、按什么顺序执行。\n\n" | |
| f"## 可用技能\n```json\n{catalog_json}\n```\n\n" | |
| "## 规则\n" | |
| "1. 只选择【表达方式】中明确提到的技能\n" | |
| "2. 如果【表达方式】中同时包含语音和多条拆分,只选择 modality=语音,忽略多条拆分\n" | |
| "3. 内容生成类技能(照片、语音)排在前面,投递方式类技能(多条拆分)排在后面\n" | |
| "4. 如果没有匹配任何技能,返回空数组 []\n" | |
| "5. 每个技能条目必须包含 modality 和 params\n\n" | |
| "## 输出格式\n" | |
| "返回 JSON 数组,按执行顺序排列:\n" | |
| '```json\n[{"modality": "照片", "params": {...}}, {"modality": "多条拆分", "params": {...}}]\n```\n\n' | |
| "对于每个技能,params 的格式参考该技能的 SKILL.md 文档(会在激活时提供)。\n" | |
| "现在只需要返回 modality 列表,params 设为空对象 {} 即可。" | |
| ) | |
| user_prompt = f"【表达方式】原文:{raw_modality}" | |
| messages = [ | |
| ChatMessage("system", system_prompt), | |
| ChatMessage("user", user_prompt), | |
| ] | |
| try: | |
| response = await llm.chat(messages, temperature=0.1) | |
| plan = self._extract_json(response.content) | |
| except Exception as e: | |
| print(f" [skill-plan] ❌ Planning failed: {e}") | |
| plan = None | |
| # Fallback: if planning fails, try simple keyword matching | |
| if not plan or not isinstance(plan, list): | |
| plan = [] | |
| for skill in self._skills.values(): | |
| if skill.modality and skill.modality in raw_modality: | |
| plan.append({"modality": skill.modality, "params": {}}) | |
| if plan: | |
| print(f" [skill-plan] ⚠ LLM plan failed, fallback to keyword matching: {[p['modality'] for p in plan]}") | |
| if not plan: | |
| return [] | |
| # Apply excludes rules declared in SKILL.md frontmatter | |
| plan_modalities = {p.get("modality") for p in plan} | |
| for skill in self._skills.values(): | |
| if skill.excludes and skill.modality in plan_modalities: | |
| plan = [p for p in plan if p.get("modality") not in skill.excludes] | |
| print(f" [skill-plan] 📋 Plan: {[p.get('modality') for p in plan]}") | |
| # Execute each skill in plan order | |
| results: List[SkillExecutionResult] = [] | |
| for step in plan: | |
| modality = step.get("modality", "") | |
| skill = self.get_by_modality(modality) | |
| if not skill: | |
| print(f" [skill-plan] ⚠ Unknown modality '{modality}', skipping") | |
| continue | |
| print(f" [skill] 🎯 modality='{modality}' (from plan)") | |
| result = await self.execute(modality, raw_output, persona, llm, chat_history=chat_history) | |
| if result: | |
| result.output["_modality"] = modality # inject plan's modality | |
| results.append(result) | |
| return results | |
| # -- Single-Skill Execution (L3) ------------------------------------------- | |
| async def execute( | |
| self, | |
| modality: str, | |
| raw_output: str, | |
| persona, | |
| llm, | |
| chat_history: list = None, | |
| ) -> Optional[SkillExecutionResult]: | |
| """Execute a modality skill — prompt-driven, no function calling. | |
| Flow: | |
| 1. Inject SKILL.md body as LLM instruction | |
| 2. LLM outputs structured JSON | |
| 3. Engine parses JSON | |
| 4. Engine executes tools via ToolRegistry | |
| Fast path: split_messages skips LLM (pure text processing). | |
| """ | |
| skill = self.get_by_modality(modality) | |
| if not skill: | |
| return None | |
| if not skill.is_activated: | |
| self.activate(skill.skill_id) | |
| # Route: prompt-driven tool-use vs legacy handler | |
| if skill.tools and self.tool_registry: | |
| return await self._execute_via_prompt(skill, raw_output, persona, llm, chat_history=chat_history) | |
| elif skill.handler_fn: | |
| return await self._execute_via_handler(skill, raw_output, persona, llm) | |
| else: | |
| print(f" [modality-skill] ⚠ {skill.name}: no tools or handler_fn") | |
| return None | |
| # -- Prompt-driven execution (Claude Skill pattern) ---------------------- | |
| def _build_chat_summary(chat_history, persona_name: str, max_turns: int = 6, max_chars: int = 600) -> str: | |
| """Build a concise chat summary for skill context injection.""" | |
| if not chat_history: | |
| return "(无历史对话)" | |
| recent = chat_history[-max_turns:] | |
| lines = [] | |
| for m in recent: | |
| role = "用户" if m.role == "user" else persona_name | |
| lines.append(f"{role}: {m.content[:100]}") | |
| return "\n".join(lines)[:max_chars] | |
| async def _execute_via_prompt( | |
| self, | |
| skill: Skill, | |
| raw_output: str, | |
| persona, | |
| llm, | |
| chat_history: list = None, | |
| ) -> SkillExecutionResult: | |
| """Execute skill via prompt-driven structured output. | |
| 1. Build prompt from SKILL.md body + context | |
| 2. LLM outputs JSON (following SKILL.md format instructions) | |
| 3. Engine parses JSON | |
| 4. Engine executes tools based on parsed parameters | |
| """ | |
| from providers.llm.base import ChatMessage | |
| try: | |
| # Build prompt — SKILL.md body IS the instruction | |
| # Conditionally inject chat history (only if skill declares needs_chat_history) | |
| chat_block = "" | |
| if skill.needs_chat_history and chat_history: | |
| chat_summary = self._build_chat_summary(chat_history, persona.name) | |
| chat_block = f"## 最近对话\n{chat_summary}\n\n" | |
| system_prompt = ( | |
| f"{skill.body}\n\n" | |
| f"---\n" | |
| f"## 当前上下文\n\n" | |
| f"角色名:{persona.name}\n" | |
| f"角色ID:{persona.persona_id}\n\n" | |
| f"{chat_block}" | |
| f"角色回复(JSON):\n{raw_output}\n\n" | |
| f"---\n" | |
| f"请根据上述技能文档和角色回复上下文,直接输出 JSON。只输出 JSON,不要其他内容。" | |
| ) | |
| # Pre-inject voice_preset for voice skills | |
| if "synthesize_voice" in skill.tools: | |
| voice_preset = self._resolve_voice_preset(persona) | |
| system_prompt += f"\n\n(系统预设 voice_preset: {voice_preset})" | |
| messages = [ | |
| ChatMessage("system", system_prompt), | |
| ChatMessage("user", "请输出 JSON。"), | |
| ] | |
| # Call LLM — NO tools parameter, pure text output | |
| response = await llm.chat(messages, temperature=0.3) | |
| # Parse JSON from LLM response | |
| params = self._extract_json(response.content) | |
| if not params: | |
| print(f" [modality-skill] ⚠ Failed to parse JSON from LLM output") | |
| print(f" [modality-skill] raw: {response.content[:200]}") | |
| return SkillExecutionResult( | |
| skill_id=skill.skill_id, | |
| success=False, | |
| status=ExecutionStatus.FAILED, | |
| output={"error": "Failed to parse JSON from LLM", "raw": response.content[:500]}, | |
| ) | |
| print(f" [modality-skill] 📋 LLM params: {json.dumps(params, ensure_ascii=False)[:200]}") | |
| # Execute tools based on skill type and parsed params | |
| return await self._dispatch_tools(skill, params, persona) | |
| except Exception as e: | |
| print(f" [modality-skill] ❌ Prompt-driven execution failed: {e}") | |
| return SkillExecutionResult( | |
| skill_id=skill.skill_id, | |
| success=False, | |
| status=ExecutionStatus.FAILED, | |
| output={"error": str(e)}, | |
| ) | |
| async def _dispatch_tools( | |
| self, | |
| skill: Skill, | |
| params: dict, | |
| persona, | |
| ) -> SkillExecutionResult: | |
| """Dispatch tool calls based on parsed params. | |
| The engine knows the tool orchestration logic for each skill — | |
| this is deterministic, not LLM-decided. | |
| """ | |
| output = {} | |
| # ── Photo skill: get_reference_image → generate_photo ── | |
| if "generate_photo" in skill.tools: | |
| # Step 1: Collect reference images (supports list, fallback to single) | |
| ref_types = params.get("reference_types") or [] | |
| if not ref_types: | |
| single = params.get("reference_type") | |
| if single and single != "null": | |
| ref_types = [single] | |
| reference_images = [] | |
| if ref_types and self.tool_registry.has("get_reference_image"): | |
| for rt in ref_types: | |
| ref_result = await self.tool_registry.execute("get_reference_image", { | |
| "persona_id": persona.persona_id, | |
| "reference_type": rt, | |
| }) | |
| output.update(ref_result) | |
| if ref_result.get("available"): | |
| reference_images.append(ref_result["image_path"]) | |
| else: | |
| print(f" [modality-skill] ⚠ {rt} not available, skipping") | |
| # Step 2: Generate photo (with 2x silent retry) | |
| gen_params = { | |
| "prompt": params.get("prompt", ""), | |
| "persona_id": persona.persona_id, | |
| "aspect_ratio": params.get("aspect_ratio", "9:16"), | |
| } | |
| if reference_images: | |
| gen_params["reference_images"] = reference_images | |
| gen_result = await self._retry_tool("generate_photo", gen_params) | |
| output.update(gen_result) | |
| # ── Voice skill: synthesize_voice ── | |
| elif "synthesize_voice" in skill.tools: | |
| voice_preset = self._resolve_voice_preset(persona) | |
| voice_result = await self._retry_tool("synthesize_voice", { | |
| "text": params.get("text", ""), | |
| "voice_preset": voice_preset, | |
| "emotion_instruction": params.get("emotion_instruction", ""), | |
| }) | |
| output.update(voice_result) | |
| # ── Split skill: split_messages ── | |
| elif "split_messages" in skill.tools: | |
| split_params = {"text": params.get("text", "")} | |
| if params.get("delays_ms"): | |
| split_params["delays_ms"] = params["delays_ms"] | |
| split_result = await self.tool_registry.execute("split_messages", split_params) | |
| output.update(split_result) | |
| # Determine success — check tool's own flag first, then known output keys | |
| success = output.pop("success", False) | |
| if not success: | |
| success = bool(output.get("image_path") or output.get("audio_path") or output.get("segments")) | |
| status_str = "✅" if success else "❌" | |
| print(f" [modality-skill] {status_str} {skill.name} {'completed' if success else 'failed'}") | |
| return SkillExecutionResult( | |
| skill_id=skill.skill_id, | |
| success=success, | |
| status=ExecutionStatus.COMPLETED if success else ExecutionStatus.FAILED, | |
| output=output, | |
| ) | |
| # -- Helpers --------------------------------------------------------------- | |
| async def _retry_tool(self, tool_name: str, params: dict, max_retries: int = 2) -> dict: | |
| """Execute a tool with silent retries for transient errors. | |
| Retries the same call up to max_retries times. | |
| Only the final failure propagates up to the engine. | |
| """ | |
| import asyncio | |
| last_result = {} | |
| for attempt in range(1, max_retries + 2): # 1 initial + max_retries | |
| result = await self.tool_registry.execute(tool_name, params) | |
| success = result.get("success", False) | |
| if not success: | |
| success = bool(result.get("image_path") or result.get("audio_path")) | |
| if success: | |
| return result | |
| last_result = result | |
| if attempt <= max_retries: | |
| print(f" [tool] 🔄 {tool_name} retry {attempt}/{max_retries}") | |
| await asyncio.sleep(1) # brief pause before retry | |
| return last_result | |
| def _extract_json(self, text: str): | |
| """Extract JSON (object or array) from LLM text output.""" | |
| text = text.strip() | |
| # Try direct parse (object or array) | |
| if text.startswith(("{", "[")): | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| # Strip markdown ```json ... ``` fence and try direct parse | |
| stripped = re.sub(r"^```(?:json)?\s*\n?", "", text) | |
| stripped = re.sub(r"\n?\s*```\s*$", "", stripped).strip() | |
| if stripped != text and stripped.startswith(("{", "[")): | |
| try: | |
| return json.loads(stripped) | |
| except json.JSONDecodeError: | |
| # Try sanitizing Chinese curly quotes inside JSON string values | |
| sanitized = stripped.replace('\u201c', '\\"').replace('\u201d', '\\"') | |
| try: | |
| return json.loads(sanitized) | |
| except json.JSONDecodeError: | |
| pass | |
| # Try extracting from ```json ... ``` block (object or array) | |
| m = re.search(r"```(?:json)?\s*\n?([{\[].*?[}\]])\s*\n?```", text, re.DOTALL) | |
| if m: | |
| try: | |
| return json.loads(m.group(1)) | |
| except json.JSONDecodeError: | |
| pass | |
| # Try finding [...] block (for plan arrays) | |
| m = re.search(r"\[\s*\{.*?\}\s*\]", text, re.DOTALL) | |
| if m: | |
| try: | |
| return json.loads(m.group(0)) | |
| except json.JSONDecodeError: | |
| pass | |
| # Try finding {...} block | |
| m = re.search(r"\{[^{}]*\}", text, re.DOTALL) | |
| if m: | |
| try: | |
| return json.loads(m.group(0)) | |
| except json.JSONDecodeError: | |
| pass | |
| return None | |
| def _resolve_voice_preset(self, persona) -> str: | |
| """Pre-resolve voice_preset from api.yaml voice_map.""" | |
| try: | |
| from providers.config import _load as _load_config | |
| _tts_cfg = _load_config().get("tts", {}) | |
| _voice_map = _tts_cfg.get("voice_map", {}) | |
| _default_voice = _tts_cfg.get("providers", {}).get( | |
| _tts_cfg.get("provider", ""), {} | |
| ).get("default_voice", "Cherry") | |
| return _voice_map.get(persona.persona_id, _default_voice) | |
| except Exception: | |
| return "Cherry" | |
| # -- Legacy path (fallback) ----------------------------------------------- | |
| async def _execute_via_handler( | |
| self, | |
| skill: Skill, | |
| raw_output: str, | |
| persona, | |
| llm, | |
| ) -> Optional[SkillExecutionResult]: | |
| """Legacy handler_fn path. Kept during migration.""" | |
| from providers.llm.base import ChatMessage | |
| if not skill.handler_fn: | |
| return None | |
| try: | |
| system_msg = ChatMessage("system", | |
| f"根据以下技能文档和角色回复上下文,生成该技能的结构化输出。\n" | |
| f"只输出结构化内容,不要多余解释。\n\n{skill.body}" | |
| ) | |
| user_msg = ChatMessage("user", | |
| f"角色回复:{raw_output}\n角色名:{persona.name}" | |
| ) | |
| prompt_resp = await llm.chat([system_msg, user_msg], temperature=0.3) | |
| module_path, fn_name = skill.handler_fn.rsplit('.', 1) | |
| mod = importlib.import_module(module_path) | |
| handler = getattr(mod, fn_name) | |
| voice_preset = self._resolve_voice_preset(persona) | |
| result = await handler( | |
| persona_id=persona.persona_id, | |
| raw_output=prompt_resp.content, | |
| persona_name=persona.name, | |
| voice_preset=voice_preset, | |
| base_instructions=getattr(persona.voice, 'description', '') or '', | |
| ) | |
| success = result.get("success", False) | |
| return SkillExecutionResult( | |
| skill_id=skill.skill_id, | |
| success=success, | |
| status=ExecutionStatus.COMPLETED if success else ExecutionStatus.FAILED, | |
| output=result, | |
| ) | |
| except Exception as e: | |
| print(f" [modality-skill] ❌ handler error: {e}") | |
| return SkillExecutionResult( | |
| skill_id=skill.skill_id, | |
| success=False, | |
| status=ExecutionStatus.FAILED, | |
| output={"error": str(e)}, | |
| ) | |