openher / agent /skills /task_skill_engine.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
17.7 kB
"""
TaskSkillEngine — VERA-inspired ReAct loop for task-oriented skills.
Architecture: Prompt-driven ReAct (no function calling dependency).
L1 build_catalog() → inject skill metadata into ReAct prompt
L2 activate() → JIT inject SKILL.md body on LLM request
L3 react_loop() → ReAct cycle: Thought → Action → Observation
Execution paths:
executor=sandbox → LLM generates shell command → execute_shell()
executor=handler → LLM generates params → ToolRegistry.execute()
Supports parallel (multiple actions per round) and serial (multi-round) chaining.
"""
from __future__ import annotations
import asyncio
import json
import re
from pathlib import Path
from typing import Optional
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 TaskSkillEngine:
"""Task-oriented skill engine with ReAct loop."""
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 ---------------------------------------------------------------
def load_all(self) -> dict[str, Skill]:
"""Load L1 metadata for trigger=tool 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 == "tool":
self._skills[skill.skill_id] = skill
except Exception as e:
print(f"[task-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 get(self, skill_id: str) -> Optional[Skill]:
if not self._skills:
self.load_all()
return self._skills.get(skill_id)
@property
def tool_skills(self) -> list[Skill]:
"""List of trigger:tool skills."""
return [s for s in self._skills.values() if s.trigger == "tool"]
def get_cron_skills(self) -> list[Skill]:
"""Get all skills with cron triggers."""
if not self._skills:
self.load_all()
return [s for s in self._skills.values() if s.trigger == "cron" and s.cron_schedule]
# -- L1 Catalog (Progressive Disclosure) -----------------------------------
def build_catalog(self) -> str:
"""Build L1 skill catalog text for ReAct prompt injection.
Returns a concise description of available skills (metadata only).
"""
if not self._skills:
self.load_all()
if not self.tool_skills:
return ""
lines = ["可用工具技能:"]
for skill in self.tool_skills:
lines.append(f"- {skill.skill_id}: {skill.description}")
return "\n".join(lines)
# -- ReAct Loop ------------------------------------------------------------
async def react_loop(
self,
user_message: str,
llm,
max_rounds: int = 3,
) -> Optional[str]:
"""Run a pre-engine ReAct loop for task skill detection + execution.
Pure prompt-driven — no function calling dependency.
Flow:
Round 1: LLM sees skill catalog (L1) + user message
→ outputs nothing (no skill needed) or {"activate": "skill_id"}
Round 2+: Engine JIT injects SKILL.md body (L2)
→ LLM outputs {"actions": [...]} or {"done": true}
→ Engine executes actions (sandbox or ToolRegistry)
→ Observations fed back for next round
Returns:
Merged observation text to inject into user_message, or None.
"""
from providers.llm.base import ChatMessage
if not self._skills:
self.load_all()
if not self.tool_skills:
return None
catalog = self.build_catalog()
if not catalog:
return None
# Build ReAct system prompt
system_prompt = (
"你是一个工具调度器。判断用户消息是否需要调用工具。\n\n"
f"## {catalog}\n\n"
"## 协议\n"
"- 如果用户消息**直接、明确**地请求了某个技能的能力,输出 JSON:\n"
' {"activate": "skill_id"}\n\n'
"- 如果已有技能文档,需要执行动作:\n"
' {"actions": [{"tool": "execute_shell", "params": {"command": "..."}}]}\n\n'
"- **其他所有情况**,什么都不要输出,返回空。\n\n"
"## 严格规则\n"
"- 99% 的消息都不需要工具,默认返回空\n"
"- 聊天、闲聊、提问、情感表达、讨论话题 → 返回空\n"
"- 不要联想、不要推测用户可能需要什么工具\n"
"- 用户没有明说要用工具,就不要激活\n"
)
messages = [ChatMessage("system", system_prompt)]
messages.append(ChatMessage("user", user_message))
all_observations: list[str] = []
active_skill: Optional[Skill] = None
for round_idx in range(max_rounds):
try:
response = await llm.chat(messages, temperature=0.1, max_tokens=500)
raw = response.content.strip()
except Exception as e:
print(f" [react] ❌ Round {round_idx + 1} LLM error: {e}")
break
# Empty output = LLM decided no skill needed → silent return
if not raw:
break
parsed = self._extract_json(raw)
if not parsed:
# LLM output non-JSON (e.g. "不需要") = no skill needed
break
# done = no skill needed (backward compat)
if parsed.get("done"):
break
# activate_skill — JIT inject SKILL.md body (L2)
if "activate" in parsed:
skill_id = parsed["activate"].lower()
skill = self._skills.get(skill_id)
if not skill:
print(f" [react] ⚠ Unknown skill: {skill_id}")
break
print(f" [react] 🎯 Activate: {skill_id} (round {round_idx + 1})")
self.activate(skill_id)
active_skill = skill
# JIT inject SKILL.md body into context
skill_injection = (
f"技能 [{skill.name}] 已激活。以下是技能文档:\n\n"
f"{skill.body}\n\n"
f"请根据技能文档和用户请求,生成具体的执行动作。"
)
messages.append(ChatMessage("assistant", response.content))
messages.append(ChatMessage("user", skill_injection))
continue
# actions — execute via sandbox or ToolRegistry
actions = parsed.get("actions", [])
if not actions:
break
thought = parsed.get("thought", "")
print(f" [react] 🔧 Actions (round {round_idx + 1}): "
f"{len(actions)} action(s), thought: {thought[:60]}")
# Parallel execution via asyncio.gather
tasks = [self._execute_action(a, active_skill) for a in actions]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Collect observations
round_observations = []
for i, result in enumerate(results):
if isinstance(result, Exception):
obs = f"[错误] {result}"
elif isinstance(result, str):
obs = result
else:
obs = str(result)
round_observations.append(obs)
all_observations.append(obs)
# Feed observations back for next round
obs_text = "\n".join(f"[Observation {i+1}] {o}" for i, o in enumerate(round_observations))
messages.append(ChatMessage("assistant", response.content))
messages.append(ChatMessage("user",
f"执行结果:\n{obs_text}\n\n"
f"根据结果,是否需要更多操作?如果完成,返回 {{\"done\": true}}。"
))
if not all_observations:
return None
# P4 fix: per-observation limit + truncation marker
MAX_PER_OBS = 300
trimmed = []
for obs in all_observations:
if len(obs) > MAX_PER_OBS:
trimmed.append(obs[:MAX_PER_OBS] + "…(已截断)")
else:
trimmed.append(obs)
merged = "\n".join(trimmed)
print(f" [react] 📋 Total observations: {len(all_observations)}, {len(merged)} chars")
return merged
# -- Action Execution ------------------------------------------------------
async def _execute_action(
self,
action: dict,
active_skill: Optional[Skill],
) -> str:
"""Execute a single action from the ReAct output.
Routes to sandbox (execute_shell) or ToolRegistry based on action type.
"""
tool_name = action.get("tool", "execute_shell")
params = action.get("params", {})
# Sandbox path
if tool_name == "execute_shell":
command = params.get("command", "")
if not command:
return "[错误] 空命令"
# Clean markdown wrapping
command = re.sub(r'^```\w*\n?', '', command)
command = re.sub(r'\n?```$', '', command)
command = command.strip()
from agent.skills.sandbox_executor import execute_shell
result = await execute_shell(command)
stdout = result.get("stdout", "").strip()
stderr = result.get("stderr", "").strip()
if result["success"]:
return stdout or "[执行成功,无输出]"
else:
return f"[执行失败] {stderr or stdout or '未知错误'}"
# ToolRegistry path
if self.tool_registry and self.tool_registry.has(tool_name):
try:
result = await self.tool_registry.execute(tool_name, params)
return json.dumps(result, ensure_ascii=False)[:500]
except Exception as e:
return f"[工具错误] {tool_name}: {e}"
return f"[未知工具] {tool_name}"
async def _execute_with_skill(
self,
skill: Skill,
user_message: str,
llm,
) -> Optional[str]:
"""Fallback: execute a skill directly (keyword match path).
Used when JSON parsing fails but keyword matching finds a skill.
"""
from providers.llm.base import ChatMessage
if not skill.body:
return None
system_msg = ChatMessage("system",
f"根据以下技能文档,为用户请求生成一条可执行的 shell 命令。\n"
f"只输出命令本身,不要解释,不要 markdown 格式。\n\n"
f"## 技能文档\n{skill.body}"
)
user_msg = ChatMessage("user", user_message)
resp = await llm.chat([system_msg, user_msg], temperature=0.1)
content = resp.content.strip()
content = re.sub(r'^```\w*\n?', '', content)
content = re.sub(r'\n?```$', '', content)
command = content.strip()
if not command:
return None
from agent.skills.sandbox_executor import execute_shell
result = await execute_shell(command)
stdout = result.get("stdout", "").strip()
if result["success"] and stdout:
return stdout
return None
# -- Keyword Fallback ------------------------------------------------------
def _keyword_match(self, user_message: str) -> Optional[Skill]:
"""Simple keyword matching fallback when LLM JSON fails."""
msg_lower = user_message.lower()
for skill in self.tool_skills:
# Check skill name and description keywords
triggers = [skill.skill_id, skill.name]
desc_words = skill.description.split()
triggers.extend(w for w in desc_words if len(w) >= 5)
for trigger in triggers:
if trigger.lower() in msg_lower:
return skill
return None
# -- JSON Extraction -------------------------------------------------------
def _extract_json(self, text: str) -> Optional[dict]:
"""Extract JSON object from LLM text output."""
text = text.strip()
# Direct parse
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Strip markdown ```json ... ``` fence
stripped = re.sub(r"^```(?:json)?\s*\n?", "", text)
stripped = re.sub(r"\n?\s*```\s*$", "", stripped).strip()
if stripped.startswith("{"):
try:
return json.loads(stripped)
except json.JSONDecodeError:
pass
# P1 fix: bracket-counting extraction (replaces greedy regex)
obj_str = self._find_first_json_object(text)
if obj_str:
try:
return json.loads(obj_str)
except json.JSONDecodeError:
pass
return None
@staticmethod
def _find_first_json_object(text: str) -> Optional[str]:
"""Find the first balanced {...} block using bracket counting."""
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if escape:
escape = False
continue
if c == "\\" and in_string:
escape = True
continue
if c == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return None
# -- Legacy compat (kept for tests referencing execute()) ------------------
async def execute(self, skill_id: str, user_intent: str, llm) -> SkillExecutionResult:
"""Execute a task skill directly. Legacy path, prefer react_loop().
Args:
skill_id: ID of the skill to execute.
user_intent: Original user message.
llm: LLMClient instance for command generation.
"""
from providers.llm.base import ChatMessage
skill_id = skill_id.lower()
skill = self._skills.get(skill_id)
if not skill:
return SkillExecutionResult(
skill_id=skill_id, success=False,
status=ExecutionStatus.FAILED,
output={"error": f"Unknown skill: {skill_id}"},
)
if not skill.is_activated:
self.activate(skill_id)
if not skill.body:
return SkillExecutionResult(
skill_id=skill_id, success=False,
status=ExecutionStatus.FAILED,
output={"error": "Skill body is empty", "stdout": "", "stderr": "", "returncode": -1},
)
# LLM generates shell command from body + user intent
system_msg = ChatMessage("system",
f"根据以下技能文档,为用户请求生成一条可执行的 shell 命令。\n"
f"只输出命令本身,不要解释,不要 markdown 格式。\n\n"
f"## 技能文档\n{skill.body}"
)
user_msg = ChatMessage("user", user_intent)
resp = await llm.chat([system_msg, user_msg], temperature=0.1)
content = resp.content.strip()
content = re.sub(r'^```\w*\n?', '', content)
content = re.sub(r'\n?```$', '', content)
command = content.strip()
if not command:
return SkillExecutionResult(
skill_id=skill_id, success=False,
status=ExecutionStatus.FAILED,
output={"error": "LLM generated empty command", "stdout": "", "stderr": "", "returncode": -1},
)
from agent.skills.sandbox_executor import execute_shell
result = await execute_shell(command)
return SkillExecutionResult(
skill_id=skill_id,
success=result["success"],
status=ExecutionStatus.COMPLETED if result["success"] else ExecutionStatus.FAILED,
output={**result, "command": command},
)