feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
363
coworker/personas/manifest.py
Normal file
363
coworker/personas/manifest.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Persona manifest — parse + validate a persona definition.
|
||||
|
||||
Format: YAML frontmatter (identity + capability declaration) followed by a markdown body that
|
||||
is the system prompt. `persona ⊇ skill` — the same frontmatter-markdown shape as SKILL.md, with
|
||||
more structured fields. Parsing is strict: an invalid manifest raises ``ManifestError`` rather
|
||||
than silently producing a broken persona (a third-party persona must fail loudly).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# Persona ids become directory names under the managed install area (and registry keys), so
|
||||
# they are restricted to a filesystem-safe slug on every OS: no path separators or `..`
|
||||
# (traversal), no `:*?"<>|` (invalid on Windows), bounded length.
|
||||
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
VALID_FAMILIES = {"code", "knowledge"} # legacy key, shimmed in parse()
|
||||
VALID_TEAM = {"lead", "worker"}
|
||||
# "auto" kept as the legacy spelling of "bypass-approvals" (Mode._missing_).
|
||||
VALID_MODES = {"discuss", "plan", "interactive", "custom", "auto", "bypass-approvals", "auto-approve"}
|
||||
VALID_REC_KINDS = {"connector", "mcp"}
|
||||
VALID_REC_TIERS = {"core", "optional"}
|
||||
VALID_GROUPS = {"general", "security"}
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""A persona manifest is malformed or references unknown capabilities/values."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recommendation:
|
||||
"""A connection a persona recommends, surfaced in the per-session connections drawer. ``ref`` is a
|
||||
connector id or an MCP server name; ``reason`` is the value it unlocks; ``tier`` ranks it. Not
|
||||
validated against shipped connectors — a persona may recommend one we don't ship yet.
|
||||
"""
|
||||
|
||||
kind: str # "connector" | "mcp"
|
||||
ref: str
|
||||
reason: str = ""
|
||||
tier: str = "optional" # "core" | "optional"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaManifest:
|
||||
id: str
|
||||
name: str
|
||||
system_prompt: str
|
||||
icon: str = ""
|
||||
tagline: str = ""
|
||||
description: str = ""
|
||||
tools: list[str] = field(default_factory=list)
|
||||
# Workspace/toolset traits (workspace-scratch-design.md — replaces the old
|
||||
# family/workspace pair). requires_folder: the composer/engine gate on a
|
||||
# user-picked primary folder. subagents: explorer fan-out. scheduling:
|
||||
# scheduled tasks + self-wake (defaults to the opposite of requires_folder
|
||||
# when the manifest is silent — folder personas fan out instead).
|
||||
requires_folder: bool = False
|
||||
subagents: bool = False
|
||||
scheduling: bool = True
|
||||
messaging: bool = False
|
||||
# Connector grant (OPE-93): False = none, a tuple = allowlist of connector ids
|
||||
# (session exposes declared ∩ connected), True = every connected connector — the
|
||||
# `all` sentinel, reserved for built-in general personas. Coarser grants leaked
|
||||
# undeclared tools (browser, email) into security sessions; undeclared = absent.
|
||||
connectors: bool | tuple[str, ...] = False
|
||||
# Team identity (agent-teams design, third/fourth pass): "lead" = coordinates a
|
||||
# team (gets the board coordination verbs + gates; consent copy says "can create
|
||||
# and direct worker coworkers"); "worker" = purpose-built to work under a lead
|
||||
# (board worker verbs, no ask_user-shaped prompt); None = solo-only. Solo
|
||||
# personas are NOT team-eligible — team-awareness changes who the prompt talks
|
||||
# to, so staffing fails closed on personas without the trait.
|
||||
team: Optional[str] = None
|
||||
default_permission_mode: str = "interactive"
|
||||
recommended_models: list[str] = field(default_factory=list)
|
||||
skills: list[str] = field(default_factory=list)
|
||||
mcp: list[str] = field(default_factory=list)
|
||||
# Sharing v1 (OPE-7): the author's version string ("1", "1.2", "2026-08"…). Purely
|
||||
# informational provenance — with folder/git distribution there is no authoritative
|
||||
# update channel, so this drives the "replaces vN" note on re-install, nothing more.
|
||||
version: str = ""
|
||||
recommends: list[Recommendation] = field(default_factory=list)
|
||||
# Distribution decision, not a maturity claim (owner, 2026-08-21): ships:false
|
||||
# coworkers exist in the codebase but are absent from release builds — internal
|
||||
# builds opt them in via OPENWORKER_UNSHIPPED=1.
|
||||
ships: bool = True
|
||||
# Settings-page grouping ("general" | "security"). Cosmetic — grouping never
|
||||
# gates behavior, so a third-party persona claiming "security" is harmless.
|
||||
group: str = "general"
|
||||
builtin: bool = False
|
||||
source: Optional[str] = (
|
||||
None # where it was loaded from (path / url), for provenance
|
||||
)
|
||||
|
||||
def to_agent(self):
|
||||
"""Materialize the runtime Agent (prompt + catalog-expanded tools + traits)."""
|
||||
from ..agents.base import Agent
|
||||
from ..catalog import expand
|
||||
|
||||
tool_ids = list(self.tools)
|
||||
factory = (lambda ctx: expand(tool_ids, ctx)) if tool_ids else None
|
||||
return Agent(
|
||||
name=self.id,
|
||||
title=self.name,
|
||||
system_prompt=self.system_prompt,
|
||||
tool_factory=factory,
|
||||
requires_folder=self.requires_folder,
|
||||
subagents=self.subagents,
|
||||
scheduling=self.scheduling,
|
||||
messaging=self.messaging,
|
||||
connectors=self.connectors,
|
||||
team=self.team,
|
||||
)
|
||||
|
||||
|
||||
def _connectors(
|
||||
persona_id: str,
|
||||
raw: Any,
|
||||
recommends: list[Recommendation],
|
||||
builtin: bool,
|
||||
) -> bool | tuple[str, ...]:
|
||||
"""Parse the connector grant (OPE-93). Fail closed at every ambiguity.
|
||||
|
||||
- list → explicit allowlist (the normal case).
|
||||
- "all" → every connected connector; reserved for BUILT-IN general personas — a
|
||||
shared bundle claiming it is exactly the trust violation the allowlist exists
|
||||
to prevent, so third-party loads reject it.
|
||||
- legacy `true` (pre-allowlist manifests) → the connector refs the manifest already
|
||||
recommends (author intent); no recommends → no grant.
|
||||
- recommends must stay within the grant: a recommendation the coworker can't use is
|
||||
author drift, surfaced at load rather than at the user's consent screen.
|
||||
"""
|
||||
if raw is None or raw is False:
|
||||
declared: bool | tuple[str, ...] = False
|
||||
elif raw is True:
|
||||
refs = {r.ref for r in recommends if r.kind == "connector"}
|
||||
declared = tuple(sorted(refs)) if refs else False
|
||||
elif isinstance(raw, str):
|
||||
if raw.strip().lower() != "all":
|
||||
raise ManifestError(
|
||||
f"{persona_id}: `connectors` must be a list of connector ids or 'all'"
|
||||
)
|
||||
if not builtin:
|
||||
raise ManifestError(
|
||||
f"{persona_id}: `connectors: all` is reserved for built-in coworkers — "
|
||||
"declare the specific connectors this coworker uses"
|
||||
)
|
||||
declared = True
|
||||
elif isinstance(raw, list):
|
||||
declared = tuple(
|
||||
dict.fromkeys(s for s in (str(x).strip() for x in raw) if s)
|
||||
)
|
||||
else:
|
||||
raise ManifestError(
|
||||
f"{persona_id}: `connectors` must be a list of connector ids or 'all'"
|
||||
)
|
||||
|
||||
if declared is not True:
|
||||
granted = set(declared or ())
|
||||
for r in recommends:
|
||||
if r.kind == "connector" and r.ref not in granted:
|
||||
raise ManifestError(
|
||||
f"{persona_id}: recommends connector '{r.ref}' but does not declare "
|
||||
"it in `connectors` — a recommendation must stay within the grant"
|
||||
)
|
||||
return declared
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
if not text.startswith("---"):
|
||||
raise ManifestError("manifest must start with a YAML frontmatter block (---)")
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
raise ManifestError("unterminated frontmatter block (missing closing ---)")
|
||||
raw = text[3:end]
|
||||
body = text[end + 4 :].lstrip("\n")
|
||||
try:
|
||||
meta = yaml.safe_load(raw) or {}
|
||||
except yaml.YAMLError as e: # pragma: no cover - exercised via parse error path
|
||||
raise ManifestError(f"invalid YAML frontmatter: {e}") from e
|
||||
if not isinstance(meta, dict):
|
||||
raise ManifestError("frontmatter must be a mapping of key: value")
|
||||
return meta, body
|
||||
|
||||
|
||||
def _slugify(stem: str) -> str:
|
||||
"""Normalize a filename stem into the persona-id charset (used only for ids derived
|
||||
from filenames; explicit `id:` values must already be valid)."""
|
||||
slug = re.sub(r"[^a-z0-9_-]+", "-", stem.strip().lower()).strip("-_")[:64]
|
||||
return slug if _ID_RE.match(slug) else ""
|
||||
|
||||
|
||||
def _strlist(meta: dict, key: str) -> list[str]:
|
||||
val = meta.get(key, [])
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, str):
|
||||
return [v.strip() for v in val.split(",") if v.strip()]
|
||||
if isinstance(val, list):
|
||||
return [str(v).strip() for v in val if str(v).strip()]
|
||||
raise ManifestError(f"`{key}` must be a list or comma-separated string")
|
||||
|
||||
|
||||
def _recommends(persona_id: str, meta: dict) -> list[Recommendation]:
|
||||
raw = meta.get("recommends")
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(f"persona {persona_id!r}: `recommends` must be a list")
|
||||
out: list[Recommendation] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: each `recommends` item must be a mapping"
|
||||
)
|
||||
if "connector" in item:
|
||||
kind, ref = "connector", str(item.get("connector") or "").strip()
|
||||
elif "mcp" in item:
|
||||
kind, ref = "mcp", str(item.get("mcp") or "").strip()
|
||||
else:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: each `recommends` item needs a `connector:` or `mcp:` key"
|
||||
)
|
||||
if not ref:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: a `recommends` item has an empty {kind}"
|
||||
)
|
||||
tier = str(item.get("tier", "optional")).strip().lower()
|
||||
if tier not in VALID_REC_TIERS:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: recommend tier must be one of {sorted(VALID_REC_TIERS)}"
|
||||
)
|
||||
out.append(
|
||||
Recommendation(
|
||||
kind=kind,
|
||||
ref=ref,
|
||||
reason=str(item.get("reason", "")).strip(),
|
||||
tier=tier,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_manifest(
|
||||
text: str,
|
||||
*,
|
||||
fallback_id: Optional[str] = None,
|
||||
builtin: bool = False,
|
||||
source: Optional[str] = None,
|
||||
) -> PersonaManifest:
|
||||
meta, body = _split_frontmatter(text)
|
||||
|
||||
explicit_id = str(meta.get("id") or "").strip()
|
||||
if explicit_id:
|
||||
persona_id = explicit_id
|
||||
if not _ID_RE.match(persona_id):
|
||||
raise ManifestError(
|
||||
f"persona id {persona_id!r} is invalid: lowercase letters, digits, '-' or '_' "
|
||||
"only, starting with a letter/digit, max 64 chars (ids become directory names)"
|
||||
)
|
||||
else:
|
||||
# Derived from the filename: normalize it into the id charset instead of erroring,
|
||||
# so `My Persona.md` without an explicit id still installs (as `my-persona`).
|
||||
persona_id = _slugify(str(fallback_id or ""))
|
||||
if not persona_id:
|
||||
raise ManifestError(
|
||||
"manifest needs an `id` (or a filename to derive one from)"
|
||||
)
|
||||
if not body.strip():
|
||||
raise ManifestError(f"persona {persona_id!r} has no body (the system prompt)")
|
||||
|
||||
# Workspace/toolset traits (workspace-scratch-design.md). Legacy shim: pre-trait
|
||||
# bundles declared `family: code|knowledge` (and a dead `workspace:` enum, ignored
|
||||
# here) — when the new keys are absent, `family: code` maps to the folder-gated
|
||||
# profile so an old bundle keeps its gate. New keys always win.
|
||||
legacy_family = str(meta.get("family", "")).strip().lower()
|
||||
if legacy_family and legacy_family not in VALID_FAMILIES:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: family (legacy) must be one of {sorted(VALID_FAMILIES)}"
|
||||
)
|
||||
legacy_code = legacy_family == "code"
|
||||
requires_folder = bool(meta.get("requires_folder", legacy_code))
|
||||
subagents = bool(meta.get("subagents", legacy_code))
|
||||
# Folder personas fan out to explorers instead of scheduling — the silent default
|
||||
# mirrors that split; either can be declared explicitly.
|
||||
scheduling = bool(meta.get("scheduling", not requires_folder))
|
||||
|
||||
mode = str(meta.get("default_permission_mode", "interactive")).strip().lower()
|
||||
if mode not in VALID_MODES:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: default_permission_mode must be one of {sorted(VALID_MODES)}"
|
||||
)
|
||||
|
||||
group = str(meta.get("group", "general") or "general").strip().lower()
|
||||
if group not in VALID_GROUPS:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: group must be one of {sorted(VALID_GROUPS)}"
|
||||
)
|
||||
|
||||
team_raw = str(meta.get("team", "") or "").strip().lower()
|
||||
if team_raw and team_raw not in VALID_TEAM:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r}: team must be one of {sorted(VALID_TEAM)}"
|
||||
" (omit for a solo coworker)"
|
||||
)
|
||||
|
||||
tools = _strlist(meta, "tools")
|
||||
_validate_tools(persona_id, tools)
|
||||
recommends = _recommends(persona_id, meta)
|
||||
connectors = _connectors(persona_id, meta.get("connectors"), recommends, builtin)
|
||||
|
||||
return PersonaManifest(
|
||||
id=persona_id,
|
||||
name=str(meta.get("name") or persona_id).strip(),
|
||||
system_prompt=body.strip(),
|
||||
icon=str(meta.get("icon", "")).strip(),
|
||||
tagline=str(meta.get("tagline", "")).strip(),
|
||||
description=str(meta.get("description", "")).strip(),
|
||||
tools=tools,
|
||||
requires_folder=requires_folder,
|
||||
subagents=subagents,
|
||||
scheduling=scheduling,
|
||||
messaging=bool(meta.get("messaging", False)),
|
||||
connectors=connectors,
|
||||
team=team_raw or None,
|
||||
default_permission_mode=mode,
|
||||
recommended_models=_strlist(meta, "recommended_models"),
|
||||
skills=_strlist(meta, "skills"),
|
||||
mcp=_strlist(meta, "mcp"),
|
||||
version=str(meta.get("version", "") or "").strip(),
|
||||
recommends=recommends,
|
||||
ships=bool(meta.get("ships", True)),
|
||||
group=group,
|
||||
builtin=builtin,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _validate_tools(persona_id: str, tools: list[str]) -> None:
|
||||
# Imported here to avoid a module-load cycle (catalog imports agents.base).
|
||||
from ..catalog import CATALOG
|
||||
|
||||
unknown = [t for t in tools if t not in CATALOG]
|
||||
if unknown:
|
||||
raise ManifestError(
|
||||
f"persona {persona_id!r} references unknown tool capabilities: {unknown}. "
|
||||
f"Known: {sorted(CATALOG)}"
|
||||
)
|
||||
|
||||
|
||||
def load_manifest_file(path: str | Path, *, builtin: bool = False) -> PersonaManifest:
|
||||
p = Path(path)
|
||||
return parse_manifest(
|
||||
p.read_text(encoding="utf-8"),
|
||||
fallback_id=p.stem,
|
||||
builtin=builtin,
|
||||
source=str(p),
|
||||
)
|
||||
Reference in New Issue
Block a user