agent-Specialization/server/tasks/skills.py
JOJO d9bd599c1c feat(runtime): Gateway 独立启动与公共协议落地(改造第 0-3 步)
- 闭环修复:审批 task_id 显式化(N2)、通知回退路径门闸 finally 释放(N1)、
  personalization 原子写(S10,公共 atomic_write_json)
- server.tasks 拆解:__init__ 轻量化、Blueprint/路由移至 blueprint.py/web.py,
  app_legacy 装配点惰性挂载;models.py 循环依赖死导入移除
- 执行链 Web 解耦:emit_event/run_background 安全包装(socketio 未绑定静默),
  清除 4 处裸 socketio.emit(含 task_stopped 先写事件流再推送的顺序修复)
- RuntimeService 补建对话下沉:chat 任务无 conversation_id 时服务层装配,
  客户端无需复制 Web 两步流程
- 事件协议:get_task_events 返回 meta.window_start 缺口检测水位
- 验收:test_runtime_standalone_lifecycle 子进程隔离(config import 固化问题),
  独立启动 + 协议全链路 + 审批语义全绿;全量 74 测试失败恰为 4 项存量
2026-09-07 23:00:15 +08:00

152 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Workspace skill 读取与 /api/skills 接口。"""
from __future__ import annotations
from server.tasks.blueprint import tasks_bp
import re
from pathlib import Path
from typing import Dict, Any, List
from flask import request, jsonify
from flask import session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources
from server.utils_common import debug_log
from config import WORKSPACE_SKILLS_DIRNAME
from modules.skills_manager import wait_skill_file_ready
from modules.i18n import tr
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
def _parse_skill_metadata(content: str, fallback_name: str) -> Dict[str, str]:
metadata = {"name": fallback_name, "description": ""}
match = SKILL_FRONTMATTER_RE.match(content or "")
if not match:
return metadata
for raw_line in match.group("body").splitlines():
field = SKILL_FIELD_RE.match(raw_line.strip())
if not field:
continue
key = field.group("key")
value = field.group("value").strip().strip('"').strip("'")
metadata[key] = value
metadata["name"] = metadata.get("name") or fallback_name
return metadata
def _workspace_project_root(workspace) -> Path:
"""工作区根目录(已 resolve 的绝对路径)。"""
return Path(workspace.project_path).expanduser().resolve()
def _workspace_skills_dir(workspace) -> Path:
return _workspace_project_root(workspace) / WORKSPACE_SKILLS_DIRNAME
def _rel_skill_path(skill_file: Path, workspace) -> str:
"""把 skill 文件的绝对路径转成相对工作区根的路径(如 `.astrion/skills/xxx/SKILL.md`)。
前端 / 菜单插入、对话历史、任务快照均使用该相对路径,避免暴露服务器绝对路径。
"""
return str(skill_file.relative_to(_workspace_project_root(workspace)).as_posix())
def _resolve_workspace_skill_path(workspace, raw_path: str) -> Path:
try:
skills_dir = _workspace_skills_dir(workspace)
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot compute skills_dir: {exc}")
raise ValueError(tr("tasks.skill_dir_unavailable", error=exc)) from exc
debug_log(f"[SkillsAPI] resolve skill skills_dir={skills_dir!r} raw_path={raw_path!r}")
try:
target = Path(str(raw_path or "")).expanduser()
if not target.is_absolute():
target = Path(workspace.project_path).expanduser().resolve() / target
target = target.resolve()
except Exception as exc:
debug_log(f"[SkillsAPI] resolve skill cannot resolve path: {exc}")
raise ValueError(tr("tasks.skill_path_resolve_failed", error=exc)) from exc
debug_log(f"[SkillsAPI] resolve skill resolved_target={target!r} name={target.name!r}")
if target.name != "SKILL.md":
raise ValueError(tr("tasks.skill_path_not_skillmd", name=target.name))
try:
target.relative_to(skills_dir)
except ValueError as exc:
debug_log(f"[SkillsAPI] resolve skill path outside skills_dir: target={target!r} skills_dir={skills_dir!r}")
raise ValueError(tr("tasks.skill_path_outside")) from exc
# 工作区 skills 目录可能被并发的全量同步rmtree+重建)短暂清空,
# 直接 is_file() 一次失败会把瞬时窗口误判为「skill 文件不存在」拒绝任务,
# 这里改为有界等待重试,等过同步窗口再下结论。
if not wait_skill_file_ready(target, skills_dir):
debug_log(f"[SkillsAPI] resolve skill file not found (after wait): {target!r}")
raise ValueError(tr("tasks.skill_not_found"))
return target
def _list_workspace_skills(workspace) -> List[Dict[str, str]]:
skills_dir = _workspace_skills_dir(workspace)
if not skills_dir.is_dir():
return []
result: List[Dict[str, str]] = []
for skill_file in sorted(skills_dir.glob("*/SKILL.md"), key=lambda p: p.parent.name.lower()):
try:
content = skill_file.read_text(encoding="utf-8")
except Exception:
continue
metadata = _parse_skill_metadata(content, skill_file.parent.name)
result.append({
"name": metadata.get("name") or skill_file.parent.name,
"description": metadata.get("description") or "",
"path": _rel_skill_path(skill_file, workspace),
})
return result
def _build_skill_context_messages(workspace, raw_refs: Any) -> List[Dict[str, str]]:
if not isinstance(raw_refs, list):
debug_log(f"[SkillsAPI] build_skill_context raw_refs is not list: {type(raw_refs).__name__}")
return []
messages: List[Dict[str, str]] = []
seen_paths = set()
for idx, item in enumerate(raw_refs[:10]):
if not isinstance(item, dict):
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] not dict: {item!r}")
continue
raw_path = str(item.get("path") or "").strip()
debug_log(f"[SkillsAPI] build_skill_context item[{idx}] raw_path={raw_path!r} name={item.get('name')!r}")
if not raw_path:
continue
skill_file = _resolve_workspace_skill_path(workspace, raw_path)
path_key = _rel_skill_path(skill_file, workspace)
if path_key in seen_paths:
debug_log(f"[SkillsAPI] build_skill_context duplicate path skipped: {path_key}")
continue
seen_paths.add(path_key)
try:
content = skill_file.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(tr("tasks.skill_encoding_error", name=skill_file.name)) from exc
except OSError as exc:
raise ValueError(tr("tasks.skill_read_failed", error=exc)) from exc
metadata = _parse_skill_metadata(content, skill_file.parent.name)
messages.append({
"name": metadata.get("name") or skill_file.parent.name,
"path": path_key,
"content": content,
})
return messages
@tasks_bp.route("/api/skills", methods=["GET"])
@api_login_required
def list_skills_api():
username = get_current_username()
workspace_id = session.get("workspace_id") or "default"
try:
_terminal, workspace = get_user_resources(username, workspace_id)
if not workspace:
return jsonify({"success": False, "error": tr("tasks.workspace_unavailable")}), 400
return jsonify({
"success": True,
"skills": _list_workspace_skills(workspace),
})
except Exception as exc:
debug_log(f"[SkillsAPI] 列出技能失败: {exc}")
return jsonify({"success": False, "error": str(exc)}), 500