Saltar a contenido

ciel.runtime — Skill Library (Autonomía I)

Subsistema de Skill Library (Fase 12). Store dinámico escribible, verificación offline, versioning + changelog, composition, doc auto-generation, integración con ciel.Agent y métricas por tenant. Todo offline-safe.

ciel.runtime.skills_lib

Skill dataclass

Source code in src/ciel/runtime/skills.py
@dataclass
class Skill:
    name: str
    description: str
    content: str
    category: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    sha256: Optional[str] = None

SkillError

Bases: Exception

Base error for skill library operations.

Source code in src/ciel/runtime/skills_lib.py
class SkillError(Exception):
    """Base error for skill library operations."""

SkillLibrary

Writable, in-memory skill store that wraps a :class:SkillRegistry.

The registry remains the source of truth for disk-loaded skills; the library layer adds creation, registration, update (with version bump) and removal of skills that live only in memory. Tenant isolation is supported via the optional tenant_id key on each stored :class:Skill.

Source code in src/ciel/runtime/skills_lib.py
class SkillLibrary:
    """Writable, in-memory skill store that wraps a :class:`SkillRegistry`.

    The registry remains the source of truth for disk-loaded skills; the
    library layer adds creation, registration, update (with version bump) and
    removal of skills that live only in memory. Tenant isolation is supported
    via the optional ``tenant_id`` key on each stored :class:`Skill`.
    """

    def __init__(self, registry: Optional[SkillRegistry] = None) -> None:
        self.registry = registry or SkillRegistry()
        # name -> list of Skill (newest last). We keep a list so update() can
        # preserve previous_versions for the evolution tree.
        self._skills: Dict[str, List[Skill]] = {}

    # -- backed by the passive registry (backward-compatible) -----------------

    def load_from_disk(self) -> List[Skill]:
        """Discover skills from the registry roots and index them in-memory."""
        found = self.registry.discover()
        for skill in found:
            self._skills.setdefault(skill.name, []).append(skill)
        return found

    # -- writable store --------------------------------------------------------

    def _sha256(self, content: str) -> str:
        import hashlib

        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    def create_from_code(
        self,
        *,
        name: str,
        description: str,
        code: str,
        category: Optional[str] = None,
        tenant_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Skill:
        """Compile ``code`` (syntax check) and store it as a new skill.

        Raises :class:`SkillError` if the code does not compile. The skill is
        NOT executed here — execution belongs to :class:`SkillVerifier`.
        """
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

        content = code
        skill = Skill(
            name=name,
            description=description,
            content=content,
            category=category,
            metadata={
                **(metadata or {}),
                **({"tenant_id": tenant_id} if tenant_id else {}),
                "sha256": self._sha256(content),
            },
            sha256=self._sha256(content),
        )
        self._skills.setdefault(name, []).append(skill)
        return skill

    def register(self, skill: Skill) -> Skill:
        """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
        if skill.sha256 is None:
            skill.sha256 = self._sha256(skill.content)
        self._skills.setdefault(skill.name, []).append(skill)
        return skill

    def get(self, name: str) -> Optional[Skill]:
        versions = self._skills.get(name)
        if not versions:
            # Fall back to the disk registry (backward-compat lookup).
            return self.registry.get(name)
        return versions[-1]

    def list_skills(self, *, category: Optional[str] = None, tenant_id: Optional[str] = None) -> List[Skill]:
        out: List[Skill] = []
        for versions in self._skills.values():
            latest = versions[-1]
            if category is not None and latest.category != category:
                continue
            if tenant_id is not None and latest.metadata.get("tenant_id") != tenant_id:
                continue
            out.append(latest)
        # Also include any registry-only skills not yet indexed in memory.
        for skill in self.registry.list_skills(category=category):
            if skill.name not in self._skills:
                if tenant_id is None or skill.metadata.get("tenant_id") == tenant_id:
                    out.append(skill)
        return out

    def history(self, name: str) -> List[Skill]:
        """Return every stored version of ``name`` (oldest first)."""
        return list(self._skills.get(name, []))

    def remove(self, name: str) -> bool:
        if name in self._skills:
            del self._skills[name]
            return True
        return False

    def update(
        self,
        *,
        name: str,
        description: Optional[str] = None,
        code: Optional[str] = None,
        category: Optional[str] = None,
        bump: str = "patch",
    ) -> Skill:
        """Create a new version of an existing skill, preserving history.

        ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
        recorded in ``metadata.version``. The previous version is preserved in
        ``history(name)``.
        """
        versions = self._skills.get(name)
        if not versions:
            disk = self.registry.get(name)
            if disk is None:
                raise SkillError(f"cannot update unknown skill '{name}'")
            versions = [disk]
            self._skills[name] = versions

        previous = versions[-1]
        if code is None:
            code = previous.content
        else:
            try:
                compile(code, f"<skill:{name}>", "exec")
            except SyntaxError as exc:  # noqa: BLE001
                raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

        new_version = self._next_version(previous.metadata.get("version"), bump)
        updated = Skill(
            name=name,
            description=description if description is not None else previous.description,
            content=code,
            category=category if category is not None else previous.category,
            metadata={
                **previous.metadata,
                "version": new_version,
                "previous_version": previous.metadata.get("version"),
                "sha256": self._sha256(code),
            },
            sha256=self._sha256(code),
        )
        versions.append(updated)
        return updated

    @staticmethod
    def _next_version(current: Optional[str], bump: str) -> str:
        major, minor, patch = (0, 0, 0)
        if current:
            parts = (current.split(".") + ["0", "0", "0"])[:3]
            try:
                major, minor, patch = (int(p) for p in parts)
            except ValueError:
                major, minor, patch = (0, 0, 0)
        if bump == "major":
            major, minor, patch = major + 1, 0, 0
        elif bump == "minor":
            minor, patch = minor + 1, 0
        else:
            patch += 1
        return f"{major}.{minor}.{patch}"

create_from_code(*, name: str, description: str, code: str, category: Optional[str] = None, tenant_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Skill

Compile code (syntax check) and store it as a new skill.

Raises :class:SkillError if the code does not compile. The skill is NOT executed here — execution belongs to :class:SkillVerifier.

Source code in src/ciel/runtime/skills_lib.py
def create_from_code(
    self,
    *,
    name: str,
    description: str,
    code: str,
    category: Optional[str] = None,
    tenant_id: Optional[str] = None,
    metadata: Optional[Dict[str, Any]] = None,
) -> Skill:
    """Compile ``code`` (syntax check) and store it as a new skill.

    Raises :class:`SkillError` if the code does not compile. The skill is
    NOT executed here — execution belongs to :class:`SkillVerifier`.
    """
    try:
        compile(code, f"<skill:{name}>", "exec")
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

    content = code
    skill = Skill(
        name=name,
        description=description,
        content=content,
        category=category,
        metadata={
            **(metadata or {}),
            **({"tenant_id": tenant_id} if tenant_id else {}),
            "sha256": self._sha256(content),
        },
        sha256=self._sha256(content),
    )
    self._skills.setdefault(name, []).append(skill)
    return skill

history(name: str) -> List[Skill]

Return every stored version of name (oldest first).

Source code in src/ciel/runtime/skills_lib.py
def history(self, name: str) -> List[Skill]:
    """Return every stored version of ``name`` (oldest first)."""
    return list(self._skills.get(name, []))

load_from_disk() -> List[Skill]

Discover skills from the registry roots and index them in-memory.

Source code in src/ciel/runtime/skills_lib.py
def load_from_disk(self) -> List[Skill]:
    """Discover skills from the registry roots and index them in-memory."""
    found = self.registry.discover()
    for skill in found:
        self._skills.setdefault(skill.name, []).append(skill)
    return found

register(skill: Skill) -> Skill

Register an already-built :class:Skill (e.g. disk-loaded).

Source code in src/ciel/runtime/skills_lib.py
def register(self, skill: Skill) -> Skill:
    """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
    if skill.sha256 is None:
        skill.sha256 = self._sha256(skill.content)
    self._skills.setdefault(skill.name, []).append(skill)
    return skill

update(*, name: str, description: Optional[str] = None, code: Optional[str] = None, category: Optional[str] = None, bump: str = 'patch') -> Skill

Create a new version of an existing skill, preserving history.

bump is one of major/minor/patch (semantic) and is recorded in metadata.version. The previous version is preserved in history(name).

Source code in src/ciel/runtime/skills_lib.py
def update(
    self,
    *,
    name: str,
    description: Optional[str] = None,
    code: Optional[str] = None,
    category: Optional[str] = None,
    bump: str = "patch",
) -> Skill:
    """Create a new version of an existing skill, preserving history.

    ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
    recorded in ``metadata.version``. The previous version is preserved in
    ``history(name)``.
    """
    versions = self._skills.get(name)
    if not versions:
        disk = self.registry.get(name)
        if disk is None:
            raise SkillError(f"cannot update unknown skill '{name}'")
        versions = [disk]
        self._skills[name] = versions

    previous = versions[-1]
    if code is None:
        code = previous.content
    else:
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

    new_version = self._next_version(previous.metadata.get("version"), bump)
    updated = Skill(
        name=name,
        description=description if description is not None else previous.description,
        content=code,
        category=category if category is not None else previous.category,
        metadata={
            **previous.metadata,
            "version": new_version,
            "previous_version": previous.metadata.get("version"),
            "sha256": self._sha256(code),
        },
        sha256=self._sha256(code),
    )
    versions.append(updated)
    return updated

SkillRegistry

Source code in src/ciel/runtime/skills.py
class SkillRegistry:
    def __init__(self, roots: Optional[Sequence[str]] = None) -> None:
        self.roots: List[str] = list(roots or [])
        self.by_name: Dict[str, Skill] = {}
        self.by_category: Dict[str, List[Skill]] = {}
        self._path_seen: set[str] = set()

    def register_root(self, root: str) -> None:
        if root not in self.roots:
            self.roots.append(root)

    def discover(self) -> List[Skill]:
        self.by_name.clear()
        self.by_category.clear()
        self._path_seen.clear()
        found: List[Skill] = []
        for root in self.roots:
            if not os.path.isdir(root):
                continue
            for dirpath, _, filenames in os.walk(root):
                for filename in filenames:
                    if filename.lower() != "skill.md":
                        continue
                    path = os.path.join(dirpath, filename)
                    if path in self._path_seen:
                        continue
                    self._path_seen.add(path)
                    try:
                        skill = load_skill(path)
                    except ValueError:
                        continue
                    self.by_name[skill.name] = skill
                    if skill.category:
                        self.by_category.setdefault(skill.category, []).append(skill)
                    found.append(skill)
        return found

    def get(self, name: str) -> Optional[Skill]:
        return self.by_name.get(name)

    def list_skills(self, *, category: Optional[str] = None) -> List[Skill]:
        if category is None:
            return list(self.by_name.values())
        return list(self.by_category.get(category, []))

SkillVerificationError

Bases: SkillError

Raised when a skill fails verification.

Source code in src/ciel/runtime/skills_lib.py
class SkillVerificationError(SkillError):
    """Raised when a skill fails verification."""

SkillVerificationResult dataclass

Outcome of :meth:SkillVerifier.verify.

Source code in src/ciel/runtime/skills_lib.py
@dataclass
class SkillVerificationResult:
    """Outcome of :meth:`SkillVerifier.verify`."""

    passed: bool
    skill: str
    attempts: int = 0
    error: Optional[str] = None
    traceback: Optional[str] = None
    expected: Optional[Any] = None
    got: Optional[Any] = None

SkillVerifier

Offline verifier: syntax check + executable test cases.

A test case is a dict {"call": {...}, "expect": <value>}. The verifier executes the skill code in an isolated namespace, looks up a callable named after the skill (or the first callable defined), invokes it with call arguments and compares the result to expect.

Source code in src/ciel/runtime/skills_lib.py
class SkillVerifier:
    """Offline verifier: syntax check + executable test cases.

    A test case is a dict ``{"call": {...}, "expect": <value>}``. The verifier
    executes the skill code in an isolated namespace, looks up a callable named
    after the skill (or the first callable defined), invokes it with ``call``
    arguments and compares the result to ``expect``.
    """

    def __init__(self, library: Optional[SkillLibrary] = None) -> None:
        self.library = library

    def _resolve_callable(self, skill: Skill, namespace: Dict[str, Any]) -> Any:
        if skill.name in namespace and callable(namespace[skill.name]):
            return namespace[skill.name]
        callables = [v for v in namespace.values() if callable(v) and not v.__module__ == "builtins"]
        if not callables:
            raise SkillVerificationError(f"skill '{skill.name}' defines no callable to invoke")
        return callables[0]

    def verify(self, skill: Skill, *, test_cases: Sequence[Dict[str, Any]]) -> SkillVerificationResult:
        # 1) Syntax validation first.
        try:
            compile(skill.content, f"<skill:{skill.name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            return SkillVerificationResult(
                passed=False,
                skill=skill.name,
                attempts=0,
                error=f"syntax error: {exc}",
                traceback=str(exc),
            )

        namespace: Dict[str, Any] = {}
        try:
            exec(skill.content, namespace)  # noqa: S102 — offline, trusted-by-construction
        except Exception as exc:  # noqa: BLE001
            return SkillVerificationResult(
                passed=False,
                skill=skill.name,
                attempts=0,
                error=f"load error: {type(exc).__name__}: {exc}",
                traceback=repr(exc),
            )

        fn = self._resolve_callable(skill, namespace)

        last_traceback: Optional[str] = None
        for attempt, case in enumerate(test_cases, start=1):
            call_args = case.get("call", {}) or {}
            expected = case.get("expect")
            try:
                got = fn(**call_args)
            except Exception as exc:  # noqa: BLE001
                last_traceback = repr(exc)
                return SkillVerificationResult(
                    passed=False,
                    skill=skill.name,
                    attempts=attempt,
                    error=f"case {attempt} raised {type(exc).__name__}: {exc}",
                    traceback=last_traceback,
                    expected=expected,
                )
            if got != expected:
                return SkillVerificationResult(
                    passed=False,
                    skill=skill.name,
                    attempts=attempt,
                    error=f"case {attempt}: expected {expected!r}, got {got!r}",
                    expected=expected,
                    got=got,
                )
        return SkillVerificationResult(
            passed=True,
            skill=skill.name,
            attempts=len(test_cases),
        )

    def verify_by_name(self, name: str, *, test_cases: Sequence[Dict[str, Any]]) -> SkillVerificationResult:
        if self.library is None:
            raise SkillVerificationError("SkillVerifier was built without a library; pass the skill directly")
        skill = self.library.get(name)
        if skill is None:
            raise SkillVerificationError(f"unknown skill '{name}'")
        return self.verify(skill, test_cases=test_cases)

SkillVersion dataclass

A single immutable version entry stored in the library history.

Source code in src/ciel/runtime/skills_lib.py
@dataclass
class SkillVersion:
    """A single immutable version entry stored in the library history."""

    version: str
    sha256: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)

INITIAL_VERSION = '0.0.0' module-attribute

Skill dataclass

Source code in src/ciel/runtime/skills.py
@dataclass
class Skill:
    name: str
    description: str
    content: str
    category: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    sha256: Optional[str] = None

SkillError

Bases: Exception

Base error for skill library operations.

Source code in src/ciel/runtime/skills_lib.py
class SkillError(Exception):
    """Base error for skill library operations."""

SkillLibrary

Writable, in-memory skill store that wraps a :class:SkillRegistry.

The registry remains the source of truth for disk-loaded skills; the library layer adds creation, registration, update (with version bump) and removal of skills that live only in memory. Tenant isolation is supported via the optional tenant_id key on each stored :class:Skill.

Source code in src/ciel/runtime/skills_lib.py
class SkillLibrary:
    """Writable, in-memory skill store that wraps a :class:`SkillRegistry`.

    The registry remains the source of truth for disk-loaded skills; the
    library layer adds creation, registration, update (with version bump) and
    removal of skills that live only in memory. Tenant isolation is supported
    via the optional ``tenant_id`` key on each stored :class:`Skill`.
    """

    def __init__(self, registry: Optional[SkillRegistry] = None) -> None:
        self.registry = registry or SkillRegistry()
        # name -> list of Skill (newest last). We keep a list so update() can
        # preserve previous_versions for the evolution tree.
        self._skills: Dict[str, List[Skill]] = {}

    # -- backed by the passive registry (backward-compatible) -----------------

    def load_from_disk(self) -> List[Skill]:
        """Discover skills from the registry roots and index them in-memory."""
        found = self.registry.discover()
        for skill in found:
            self._skills.setdefault(skill.name, []).append(skill)
        return found

    # -- writable store --------------------------------------------------------

    def _sha256(self, content: str) -> str:
        import hashlib

        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    def create_from_code(
        self,
        *,
        name: str,
        description: str,
        code: str,
        category: Optional[str] = None,
        tenant_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Skill:
        """Compile ``code`` (syntax check) and store it as a new skill.

        Raises :class:`SkillError` if the code does not compile. The skill is
        NOT executed here — execution belongs to :class:`SkillVerifier`.
        """
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

        content = code
        skill = Skill(
            name=name,
            description=description,
            content=content,
            category=category,
            metadata={
                **(metadata or {}),
                **({"tenant_id": tenant_id} if tenant_id else {}),
                "sha256": self._sha256(content),
            },
            sha256=self._sha256(content),
        )
        self._skills.setdefault(name, []).append(skill)
        return skill

    def register(self, skill: Skill) -> Skill:
        """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
        if skill.sha256 is None:
            skill.sha256 = self._sha256(skill.content)
        self._skills.setdefault(skill.name, []).append(skill)
        return skill

    def get(self, name: str) -> Optional[Skill]:
        versions = self._skills.get(name)
        if not versions:
            # Fall back to the disk registry (backward-compat lookup).
            return self.registry.get(name)
        return versions[-1]

    def list_skills(self, *, category: Optional[str] = None, tenant_id: Optional[str] = None) -> List[Skill]:
        out: List[Skill] = []
        for versions in self._skills.values():
            latest = versions[-1]
            if category is not None and latest.category != category:
                continue
            if tenant_id is not None and latest.metadata.get("tenant_id") != tenant_id:
                continue
            out.append(latest)
        # Also include any registry-only skills not yet indexed in memory.
        for skill in self.registry.list_skills(category=category):
            if skill.name not in self._skills:
                if tenant_id is None or skill.metadata.get("tenant_id") == tenant_id:
                    out.append(skill)
        return out

    def history(self, name: str) -> List[Skill]:
        """Return every stored version of ``name`` (oldest first)."""
        return list(self._skills.get(name, []))

    def remove(self, name: str) -> bool:
        if name in self._skills:
            del self._skills[name]
            return True
        return False

    def update(
        self,
        *,
        name: str,
        description: Optional[str] = None,
        code: Optional[str] = None,
        category: Optional[str] = None,
        bump: str = "patch",
    ) -> Skill:
        """Create a new version of an existing skill, preserving history.

        ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
        recorded in ``metadata.version``. The previous version is preserved in
        ``history(name)``.
        """
        versions = self._skills.get(name)
        if not versions:
            disk = self.registry.get(name)
            if disk is None:
                raise SkillError(f"cannot update unknown skill '{name}'")
            versions = [disk]
            self._skills[name] = versions

        previous = versions[-1]
        if code is None:
            code = previous.content
        else:
            try:
                compile(code, f"<skill:{name}>", "exec")
            except SyntaxError as exc:  # noqa: BLE001
                raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

        new_version = self._next_version(previous.metadata.get("version"), bump)
        updated = Skill(
            name=name,
            description=description if description is not None else previous.description,
            content=code,
            category=category if category is not None else previous.category,
            metadata={
                **previous.metadata,
                "version": new_version,
                "previous_version": previous.metadata.get("version"),
                "sha256": self._sha256(code),
            },
            sha256=self._sha256(code),
        )
        versions.append(updated)
        return updated

    @staticmethod
    def _next_version(current: Optional[str], bump: str) -> str:
        major, minor, patch = (0, 0, 0)
        if current:
            parts = (current.split(".") + ["0", "0", "0"])[:3]
            try:
                major, minor, patch = (int(p) for p in parts)
            except ValueError:
                major, minor, patch = (0, 0, 0)
        if bump == "major":
            major, minor, patch = major + 1, 0, 0
        elif bump == "minor":
            minor, patch = minor + 1, 0
        else:
            patch += 1
        return f"{major}.{minor}.{patch}"

create_from_code(*, name: str, description: str, code: str, category: Optional[str] = None, tenant_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Skill

Compile code (syntax check) and store it as a new skill.

Raises :class:SkillError if the code does not compile. The skill is NOT executed here — execution belongs to :class:SkillVerifier.

Source code in src/ciel/runtime/skills_lib.py
def create_from_code(
    self,
    *,
    name: str,
    description: str,
    code: str,
    category: Optional[str] = None,
    tenant_id: Optional[str] = None,
    metadata: Optional[Dict[str, Any]] = None,
) -> Skill:
    """Compile ``code`` (syntax check) and store it as a new skill.

    Raises :class:`SkillError` if the code does not compile. The skill is
    NOT executed here — execution belongs to :class:`SkillVerifier`.
    """
    try:
        compile(code, f"<skill:{name}>", "exec")
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

    content = code
    skill = Skill(
        name=name,
        description=description,
        content=content,
        category=category,
        metadata={
            **(metadata or {}),
            **({"tenant_id": tenant_id} if tenant_id else {}),
            "sha256": self._sha256(content),
        },
        sha256=self._sha256(content),
    )
    self._skills.setdefault(name, []).append(skill)
    return skill

history(name: str) -> List[Skill]

Return every stored version of name (oldest first).

Source code in src/ciel/runtime/skills_lib.py
def history(self, name: str) -> List[Skill]:
    """Return every stored version of ``name`` (oldest first)."""
    return list(self._skills.get(name, []))

load_from_disk() -> List[Skill]

Discover skills from the registry roots and index them in-memory.

Source code in src/ciel/runtime/skills_lib.py
def load_from_disk(self) -> List[Skill]:
    """Discover skills from the registry roots and index them in-memory."""
    found = self.registry.discover()
    for skill in found:
        self._skills.setdefault(skill.name, []).append(skill)
    return found

register(skill: Skill) -> Skill

Register an already-built :class:Skill (e.g. disk-loaded).

Source code in src/ciel/runtime/skills_lib.py
def register(self, skill: Skill) -> Skill:
    """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
    if skill.sha256 is None:
        skill.sha256 = self._sha256(skill.content)
    self._skills.setdefault(skill.name, []).append(skill)
    return skill

update(*, name: str, description: Optional[str] = None, code: Optional[str] = None, category: Optional[str] = None, bump: str = 'patch') -> Skill

Create a new version of an existing skill, preserving history.

bump is one of major/minor/patch (semantic) and is recorded in metadata.version. The previous version is preserved in history(name).

Source code in src/ciel/runtime/skills_lib.py
def update(
    self,
    *,
    name: str,
    description: Optional[str] = None,
    code: Optional[str] = None,
    category: Optional[str] = None,
    bump: str = "patch",
) -> Skill:
    """Create a new version of an existing skill, preserving history.

    ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
    recorded in ``metadata.version``. The previous version is preserved in
    ``history(name)``.
    """
    versions = self._skills.get(name)
    if not versions:
        disk = self.registry.get(name)
        if disk is None:
            raise SkillError(f"cannot update unknown skill '{name}'")
        versions = [disk]
        self._skills[name] = versions

    previous = versions[-1]
    if code is None:
        code = previous.content
    else:
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

    new_version = self._next_version(previous.metadata.get("version"), bump)
    updated = Skill(
        name=name,
        description=description if description is not None else previous.description,
        content=code,
        category=category if category is not None else previous.category,
        metadata={
            **previous.metadata,
            "version": new_version,
            "previous_version": previous.metadata.get("version"),
            "sha256": self._sha256(code),
        },
        sha256=self._sha256(code),
    )
    versions.append(updated)
    return updated

SkillVersion dataclass

Enriched semantic version for a single skill release.

Carries major.minor.patch plus the human changelog text and the release timestamp (released_at) — the extra fields that turn a bare "0.1.0" string into a first-class, reviewable release record.

Source code in src/ciel/runtime/skill_versioning.py
@dataclass
class SkillVersion:
    """Enriched semantic version for a single skill release.

    Carries ``major.minor.patch`` plus the human changelog text and the release
    timestamp (``released_at``) — the extra fields that turn a bare ``"0.1.0"``
    string into a first-class, reviewable release record.
    """

    major: int = 0
    minor: int = 0
    patch: int = 0
    changelog: str = ""
    released_at: Optional[datetime] = None

    # --- construction / serialisation ------------------------------------

    @property
    def version(self) -> str:
        """``"major.minor.patch"`` string."""
        return f"{self.major}.{self.minor}.{self.patch}"

    @classmethod
    def parse(cls, version: str) -> "SkillVersion":
        """Build a :class:`SkillVersion` from a ``"major.minor.patch"`` string."""
        parts = (version.split(".") + ["0", "0", "0"])[:3]
        try:
            major, minor, patch = (int(p) for p in parts)
        except ValueError as exc:
            raise SkillError(f"invalid version string: {version!r}") from exc
        return cls(major=major, minor=minor, patch=patch)

    def bump(self, kind: str = "patch") -> "SkillVersion":
        """Return a new :class:`SkillVersion` bumped by ``major``/``minor``/``patch``."""
        kind = (kind or "patch").lower()
        if kind == "major":
            return SkillVersion(self.major + 1, 0, 0)
        if kind == "minor":
            return SkillVersion(self.major, self.minor + 1, 0)
        if kind == "patch":
            return SkillVersion(self.major, self.minor, self.patch + 1)
        raise SkillError(f"unknown bump kind: {kind!r} (expected major/minor/patch)")

    @classmethod
    def from_skill(cls, skill: Skill) -> "SkillVersion":
        """Build a :class:`SkillVersion` from a stored :class:`Skill`."""
        ver = cls.parse(_version_key(skill))
        ver.changelog = skill.metadata.get("changelog") or ""
        raw = skill.metadata.get("released_at")
        if raw:
            if isinstance(raw, datetime):
                ver.released_at = raw
            else:
                try:
                    ver.released_at = datetime.fromisoformat(raw)
                except (ValueError, TypeError):
                    ver.released_at = None
        return ver

version: str property

"major.minor.patch" string.

bump(kind: str = 'patch') -> 'SkillVersion'

Return a new :class:SkillVersion bumped by major/minor/patch.

Source code in src/ciel/runtime/skill_versioning.py
def bump(self, kind: str = "patch") -> "SkillVersion":
    """Return a new :class:`SkillVersion` bumped by ``major``/``minor``/``patch``."""
    kind = (kind or "patch").lower()
    if kind == "major":
        return SkillVersion(self.major + 1, 0, 0)
    if kind == "minor":
        return SkillVersion(self.major, self.minor + 1, 0)
    if kind == "patch":
        return SkillVersion(self.major, self.minor, self.patch + 1)
    raise SkillError(f"unknown bump kind: {kind!r} (expected major/minor/patch)")

from_skill(skill: Skill) -> 'SkillVersion' classmethod

Build a :class:SkillVersion from a stored :class:Skill.

Source code in src/ciel/runtime/skill_versioning.py
@classmethod
def from_skill(cls, skill: Skill) -> "SkillVersion":
    """Build a :class:`SkillVersion` from a stored :class:`Skill`."""
    ver = cls.parse(_version_key(skill))
    ver.changelog = skill.metadata.get("changelog") or ""
    raw = skill.metadata.get("released_at")
    if raw:
        if isinstance(raw, datetime):
            ver.released_at = raw
        else:
            try:
                ver.released_at = datetime.fromisoformat(raw)
            except (ValueError, TypeError):
                ver.released_at = None
    return ver

parse(version: str) -> 'SkillVersion' classmethod

Build a :class:SkillVersion from a "major.minor.patch" string.

Source code in src/ciel/runtime/skill_versioning.py
@classmethod
def parse(cls, version: str) -> "SkillVersion":
    """Build a :class:`SkillVersion` from a ``"major.minor.patch"`` string."""
    parts = (version.split(".") + ["0", "0", "0"])[:3]
    try:
        major, minor, patch = (int(p) for p in parts)
    except ValueError as exc:
        raise SkillError(f"invalid version string: {version!r}") from exc
    return cls(major=major, minor=minor, patch=patch)

_now_iso() -> str

Source code in src/ciel/runtime/skill_versioning.py
def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()

_version_key(skill: Skill) -> str

Normalised version key for a stored skill (initial == "0.0.0").

Source code in src/ciel/runtime/skill_versioning.py
def _version_key(skill: Skill) -> str:
    """Normalised version key for a stored skill (initial == ``"0.0.0"``)."""
    return skill.metadata.get("version") or INITIAL_VERSION

changelog(lib: SkillLibrary, name: str) -> Dict[str, str]

Return {version: changelog_text} for every version of name.

Versions are ordered oldest-first (matching lib.history). The initial version is keyed "0.0.0" when the library has not assigned an explicit semantic version yet.

Source code in src/ciel/runtime/skill_versioning.py
def changelog(lib: SkillLibrary, name: str) -> Dict[str, str]:
    """Return ``{version: changelog_text}`` for every version of ``name``.

    Versions are ordered oldest-first (matching ``lib.history``). The initial
    version is keyed ``"0.0.0"`` when the library has not assigned an explicit
    semantic version yet.
    """
    out: Dict[str, str] = {}
    for skill in lib.history(name):
        out[_version_key(skill)] = skill.metadata.get("changelog") or ""
    return out

evolution_tree(lib: SkillLibrary, name: str) -> Dict[str, Any]

Return the lineage / evolution tree of skill name.

The result is the seed of Ciel's unique Skill Evolution Tree: it records, for every version, its parent (previous_version) so the autonomous agent can see how a skill evolved / branched.

Structure::

{
  "name": <skill name>,
  "root": <version key of the base of the lineage>,
  "lineage": [root, ..., latest],          # ordered oldest -> newest
  "nodes": {
     <version>: {
        "version": <version>,
        "parent": <parent version or None>,
        "previous_version": <raw library value>,
        "children": [<child version>, ...],
        "sha256": <hash>,
        "changelog": <text>,
        "released_at": <iso string or None>,
     },
     ...
  },
}

The parent link is normalised so the very first update() (whose previous_version is None because the initial skill had no explicit version) is still chained to its true parent in history order.

Source code in src/ciel/runtime/skill_versioning.py
def evolution_tree(lib: SkillLibrary, name: str) -> Dict[str, Any]:
    """Return the lineage / evolution tree of skill ``name``.

    The result is the seed of Ciel's unique **Skill Evolution Tree**: it records,
    for every version, its parent (``previous_version``) so the autonomous agent
    can see how a skill evolved / branched.

    Structure::

        {
          "name": <skill name>,
          "root": <version key of the base of the lineage>,
          "lineage": [root, ..., latest],          # ordered oldest -> newest
          "nodes": {
             <version>: {
                "version": <version>,
                "parent": <parent version or None>,
                "previous_version": <raw library value>,
                "children": [<child version>, ...],
                "sha256": <hash>,
                "changelog": <text>,
                "released_at": <iso string or None>,
             },
             ...
          },
        }

    The ``parent`` link is *normalised* so the very first ``update()`` (whose
    ``previous_version`` is ``None`` because the initial skill had no explicit
    version) is still chained to its true parent in history order.
    """
    history = lib.history(name)
    if not history:
        raise SkillError(f"unknown skill: {name!r}")

    keys: List[str] = [_version_key(s) for s in history]
    nodes: Dict[str, Dict[str, Any]] = {}
    for idx, skill in enumerate(history):
        key = keys[idx]
        raw_prev = skill.metadata.get("previous_version")
        # Normalise the parent link: a None previous_version that is not the
        # first node chains to its predecessor (fixes the first-update quirk).
        parent = raw_prev if raw_prev is not None else (keys[idx - 1] if idx > 0 else None)
        nodes[key] = {
            "version": key,
            "parent": parent,
            "previous_version": raw_prev,
            "children": [],
            "sha256": skill.sha256,
            "changelog": skill.metadata.get("changelog") or "",
            "released_at": skill.metadata.get("released_at"),
        }

    # Build children lists from the (normalised) parent links.
    for key, node in nodes.items():
        if node["parent"] is not None and node["parent"] in nodes:
            nodes[node["parent"]]["children"].append(key)

    roots = [key for key, node in nodes.items() if node["parent"] is None]
    root = roots[0] if roots else (keys[0] if keys else None)
    lineage = list(keys)  # history order is the linear lineage

    return {
        "name": name,
        "root": root,
        "lineage": lineage,
        "nodes": nodes,
    }

set_changelog(lib: SkillLibrary, name: str, version: str, text: str) -> SkillVersion

Attach a changelog text to a specific version of skill name.

The changelog (and, if absent, the released_at timestamp) is written into the matching :class:Skill's metadata inside the passed library. The function returns the enriched :class:SkillVersion for that release.

Raises :class:SkillError if the skill or the requested version is unknown, or if text is empty.

Source code in src/ciel/runtime/skill_versioning.py
def set_changelog(lib: SkillLibrary, name: str, version: str, text: str) -> SkillVersion:
    """Attach a changelog ``text`` to a specific ``version`` of skill ``name``.

    The changelog (and, if absent, the ``released_at`` timestamp) is written into
    the matching :class:`Skill`'s ``metadata`` inside the passed library. The
    function returns the enriched :class:`SkillVersion` for that release.

    Raises :class:`SkillError` if the skill or the requested version is unknown,
    or if ``text`` is empty.
    """
    if not text:
        raise SkillError("changelog text must be a non-empty string")
    history = lib.history(name)
    if not history:
        raise SkillError(f"unknown skill: {name!r}")
    target: Optional[Skill] = None
    for skill in history:
        if _version_key(skill) == version:
            target = skill
            break
    if target is None:
        available = ", ".join(_version_key(s) for s in history)
        raise SkillError(f"skill {name!r} has no version {version!r} (available: {available})")
    target.metadata["changelog"] = text
    target.metadata.setdefault("released_at", _now_iso())
    return SkillVersion.from_skill(target)

Skill dataclass

Source code in src/ciel/runtime/skills.py
@dataclass
class Skill:
    name: str
    description: str
    content: str
    category: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    sha256: Optional[str] = None

SkillComposition dataclass

Builds a single composed :class:Skill from N source skills.

Source code in src/ciel/runtime/skill_composition.py
@dataclass
class SkillComposition:
    """Builds a single composed :class:`Skill` from N source skills."""

    # Optional library the composed skill is registered into (set per-call).
    library: Optional[SkillLibrary] = field(default=None)

    def compose(
        self,
        name: str,
        skills: Sequence[Skill],
        combinator: str,
        *,
        library: Optional[SkillLibrary] = None,
        description: Optional[str] = None,
        category: Optional[str] = None,
    ) -> Skill:
        """Fuse ``skills`` into a new :class:`Skill` named ``name``.

        Parameters
        ----------
        name:
            Name of the produced (composed) skill.
        skills:
            Source skills to combine (must be non-empty).
        combinator:
            One of ``"sequence"``, ``"parallel"`` or ``"selector"``.
        library:
            Optional :class:`SkillLibrary` to register the composed skill into.
            If omitted, ``self.library`` (if set at construction) is used.
        description / category:
            Override the auto-generated description / category.

        Returns
        -------
        Skill
            The newly built composed skill (also registered into ``library``
            when one is supplied).
        """
        if not skills:
            raise SkillCompositionError("compose() requires at least one source skill")

        callables: List[str] = []
        for skill in skills:
            main = _main_callable(skill.content, skill.name)
            if main is None:
                raise SkillCompositionError(
                    f"skill '{skill.name}' defines no callable to compose"
                )
            callables.append(main)

        if combinator not in ("sequence", "parallel", "selector"):
            raise SkillCompositionError(
                f"combinator must be 'sequence'|'parallel'|'selector', got {combinator!r}"
            )

        # Fusion: concatenate the source code bodies, then append the composed
        # function that wires the source callables together.
        bodies = "\n\n".join(skill.content.rstrip() for skill in skills)
        composed_fn = _composed_body(name, callables, combinator)
        content = f"{bodies}\n\n\n{composed_fn}\n"

        # Syntax-check the fused module before handing it back.
        try:
            compile(content, f"<composed:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillCompositionError(
                f"composed skill '{name}' has invalid syntax: {exc}"
            ) from exc

        source_names = [s.name for s in skills]
        metadata: Dict[str, Any] = {
            "composition": {
                "combinator": combinator,
                "source_skills": source_names,
                "source_callables": dict(zip(source_names, callables)),
            },
            "combinator": combinator,
            "source_skills": source_names,
            "composed_callable": name,
        }

        composed = Skill(
            name=name,
            description=description
            or f"Composed skill '{name}' combining {source_names} via {combinator}.",
            content=content,
            category=category if category is not None else (skills[0].category),
            metadata=metadata,
        )

        target = library if library is not None else self.library
        if target is not None:
            target.register(composed)

        return composed

compose(name: str, skills: Sequence[Skill], combinator: str, *, library: Optional[SkillLibrary] = None, description: Optional[str] = None, category: Optional[str] = None) -> Skill

Fuse skills into a new :class:Skill named name.

Parameters

name: Name of the produced (composed) skill. skills: Source skills to combine (must be non-empty). combinator: One of "sequence", "parallel" or "selector". library: Optional :class:SkillLibrary to register the composed skill into. If omitted, self.library (if set at construction) is used. description / category: Override the auto-generated description / category.

Returns

Skill The newly built composed skill (also registered into library when one is supplied).

Source code in src/ciel/runtime/skill_composition.py
def compose(
    self,
    name: str,
    skills: Sequence[Skill],
    combinator: str,
    *,
    library: Optional[SkillLibrary] = None,
    description: Optional[str] = None,
    category: Optional[str] = None,
) -> Skill:
    """Fuse ``skills`` into a new :class:`Skill` named ``name``.

    Parameters
    ----------
    name:
        Name of the produced (composed) skill.
    skills:
        Source skills to combine (must be non-empty).
    combinator:
        One of ``"sequence"``, ``"parallel"`` or ``"selector"``.
    library:
        Optional :class:`SkillLibrary` to register the composed skill into.
        If omitted, ``self.library`` (if set at construction) is used.
    description / category:
        Override the auto-generated description / category.

    Returns
    -------
    Skill
        The newly built composed skill (also registered into ``library``
        when one is supplied).
    """
    if not skills:
        raise SkillCompositionError("compose() requires at least one source skill")

    callables: List[str] = []
    for skill in skills:
        main = _main_callable(skill.content, skill.name)
        if main is None:
            raise SkillCompositionError(
                f"skill '{skill.name}' defines no callable to compose"
            )
        callables.append(main)

    if combinator not in ("sequence", "parallel", "selector"):
        raise SkillCompositionError(
            f"combinator must be 'sequence'|'parallel'|'selector', got {combinator!r}"
        )

    # Fusion: concatenate the source code bodies, then append the composed
    # function that wires the source callables together.
    bodies = "\n\n".join(skill.content.rstrip() for skill in skills)
    composed_fn = _composed_body(name, callables, combinator)
    content = f"{bodies}\n\n\n{composed_fn}\n"

    # Syntax-check the fused module before handing it back.
    try:
        compile(content, f"<composed:{name}>", "exec")
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillCompositionError(
            f"composed skill '{name}' has invalid syntax: {exc}"
        ) from exc

    source_names = [s.name for s in skills]
    metadata: Dict[str, Any] = {
        "composition": {
            "combinator": combinator,
            "source_skills": source_names,
            "source_callables": dict(zip(source_names, callables)),
        },
        "combinator": combinator,
        "source_skills": source_names,
        "composed_callable": name,
    }

    composed = Skill(
        name=name,
        description=description
        or f"Composed skill '{name}' combining {source_names} via {combinator}.",
        content=content,
        category=category if category is not None else (skills[0].category),
        metadata=metadata,
    )

    target = library if library is not None else self.library
    if target is not None:
        target.register(composed)

    return composed

SkillCompositionError

Bases: Exception

Raised when a composition cannot be built (e.g. no callables).

Source code in src/ciel/runtime/skill_composition.py
class SkillCompositionError(Exception):
    """Raised when a composition cannot be built (e.g. no callables)."""

SkillLibrary

Writable, in-memory skill store that wraps a :class:SkillRegistry.

The registry remains the source of truth for disk-loaded skills; the library layer adds creation, registration, update (with version bump) and removal of skills that live only in memory. Tenant isolation is supported via the optional tenant_id key on each stored :class:Skill.

Source code in src/ciel/runtime/skills_lib.py
class SkillLibrary:
    """Writable, in-memory skill store that wraps a :class:`SkillRegistry`.

    The registry remains the source of truth for disk-loaded skills; the
    library layer adds creation, registration, update (with version bump) and
    removal of skills that live only in memory. Tenant isolation is supported
    via the optional ``tenant_id`` key on each stored :class:`Skill`.
    """

    def __init__(self, registry: Optional[SkillRegistry] = None) -> None:
        self.registry = registry or SkillRegistry()
        # name -> list of Skill (newest last). We keep a list so update() can
        # preserve previous_versions for the evolution tree.
        self._skills: Dict[str, List[Skill]] = {}

    # -- backed by the passive registry (backward-compatible) -----------------

    def load_from_disk(self) -> List[Skill]:
        """Discover skills from the registry roots and index them in-memory."""
        found = self.registry.discover()
        for skill in found:
            self._skills.setdefault(skill.name, []).append(skill)
        return found

    # -- writable store --------------------------------------------------------

    def _sha256(self, content: str) -> str:
        import hashlib

        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    def create_from_code(
        self,
        *,
        name: str,
        description: str,
        code: str,
        category: Optional[str] = None,
        tenant_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Skill:
        """Compile ``code`` (syntax check) and store it as a new skill.

        Raises :class:`SkillError` if the code does not compile. The skill is
        NOT executed here — execution belongs to :class:`SkillVerifier`.
        """
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

        content = code
        skill = Skill(
            name=name,
            description=description,
            content=content,
            category=category,
            metadata={
                **(metadata or {}),
                **({"tenant_id": tenant_id} if tenant_id else {}),
                "sha256": self._sha256(content),
            },
            sha256=self._sha256(content),
        )
        self._skills.setdefault(name, []).append(skill)
        return skill

    def register(self, skill: Skill) -> Skill:
        """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
        if skill.sha256 is None:
            skill.sha256 = self._sha256(skill.content)
        self._skills.setdefault(skill.name, []).append(skill)
        return skill

    def get(self, name: str) -> Optional[Skill]:
        versions = self._skills.get(name)
        if not versions:
            # Fall back to the disk registry (backward-compat lookup).
            return self.registry.get(name)
        return versions[-1]

    def list_skills(self, *, category: Optional[str] = None, tenant_id: Optional[str] = None) -> List[Skill]:
        out: List[Skill] = []
        for versions in self._skills.values():
            latest = versions[-1]
            if category is not None and latest.category != category:
                continue
            if tenant_id is not None and latest.metadata.get("tenant_id") != tenant_id:
                continue
            out.append(latest)
        # Also include any registry-only skills not yet indexed in memory.
        for skill in self.registry.list_skills(category=category):
            if skill.name not in self._skills:
                if tenant_id is None or skill.metadata.get("tenant_id") == tenant_id:
                    out.append(skill)
        return out

    def history(self, name: str) -> List[Skill]:
        """Return every stored version of ``name`` (oldest first)."""
        return list(self._skills.get(name, []))

    def remove(self, name: str) -> bool:
        if name in self._skills:
            del self._skills[name]
            return True
        return False

    def update(
        self,
        *,
        name: str,
        description: Optional[str] = None,
        code: Optional[str] = None,
        category: Optional[str] = None,
        bump: str = "patch",
    ) -> Skill:
        """Create a new version of an existing skill, preserving history.

        ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
        recorded in ``metadata.version``. The previous version is preserved in
        ``history(name)``.
        """
        versions = self._skills.get(name)
        if not versions:
            disk = self.registry.get(name)
            if disk is None:
                raise SkillError(f"cannot update unknown skill '{name}'")
            versions = [disk]
            self._skills[name] = versions

        previous = versions[-1]
        if code is None:
            code = previous.content
        else:
            try:
                compile(code, f"<skill:{name}>", "exec")
            except SyntaxError as exc:  # noqa: BLE001
                raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

        new_version = self._next_version(previous.metadata.get("version"), bump)
        updated = Skill(
            name=name,
            description=description if description is not None else previous.description,
            content=code,
            category=category if category is not None else previous.category,
            metadata={
                **previous.metadata,
                "version": new_version,
                "previous_version": previous.metadata.get("version"),
                "sha256": self._sha256(code),
            },
            sha256=self._sha256(code),
        )
        versions.append(updated)
        return updated

    @staticmethod
    def _next_version(current: Optional[str], bump: str) -> str:
        major, minor, patch = (0, 0, 0)
        if current:
            parts = (current.split(".") + ["0", "0", "0"])[:3]
            try:
                major, minor, patch = (int(p) for p in parts)
            except ValueError:
                major, minor, patch = (0, 0, 0)
        if bump == "major":
            major, minor, patch = major + 1, 0, 0
        elif bump == "minor":
            minor, patch = minor + 1, 0
        else:
            patch += 1
        return f"{major}.{minor}.{patch}"

create_from_code(*, name: str, description: str, code: str, category: Optional[str] = None, tenant_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Skill

Compile code (syntax check) and store it as a new skill.

Raises :class:SkillError if the code does not compile. The skill is NOT executed here — execution belongs to :class:SkillVerifier.

Source code in src/ciel/runtime/skills_lib.py
def create_from_code(
    self,
    *,
    name: str,
    description: str,
    code: str,
    category: Optional[str] = None,
    tenant_id: Optional[str] = None,
    metadata: Optional[Dict[str, Any]] = None,
) -> Skill:
    """Compile ``code`` (syntax check) and store it as a new skill.

    Raises :class:`SkillError` if the code does not compile. The skill is
    NOT executed here — execution belongs to :class:`SkillVerifier`.
    """
    try:
        compile(code, f"<skill:{name}>", "exec")
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

    content = code
    skill = Skill(
        name=name,
        description=description,
        content=content,
        category=category,
        metadata={
            **(metadata or {}),
            **({"tenant_id": tenant_id} if tenant_id else {}),
            "sha256": self._sha256(content),
        },
        sha256=self._sha256(content),
    )
    self._skills.setdefault(name, []).append(skill)
    return skill

history(name: str) -> List[Skill]

Return every stored version of name (oldest first).

Source code in src/ciel/runtime/skills_lib.py
def history(self, name: str) -> List[Skill]:
    """Return every stored version of ``name`` (oldest first)."""
    return list(self._skills.get(name, []))

load_from_disk() -> List[Skill]

Discover skills from the registry roots and index them in-memory.

Source code in src/ciel/runtime/skills_lib.py
def load_from_disk(self) -> List[Skill]:
    """Discover skills from the registry roots and index them in-memory."""
    found = self.registry.discover()
    for skill in found:
        self._skills.setdefault(skill.name, []).append(skill)
    return found

register(skill: Skill) -> Skill

Register an already-built :class:Skill (e.g. disk-loaded).

Source code in src/ciel/runtime/skills_lib.py
def register(self, skill: Skill) -> Skill:
    """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
    if skill.sha256 is None:
        skill.sha256 = self._sha256(skill.content)
    self._skills.setdefault(skill.name, []).append(skill)
    return skill

update(*, name: str, description: Optional[str] = None, code: Optional[str] = None, category: Optional[str] = None, bump: str = 'patch') -> Skill

Create a new version of an existing skill, preserving history.

bump is one of major/minor/patch (semantic) and is recorded in metadata.version. The previous version is preserved in history(name).

Source code in src/ciel/runtime/skills_lib.py
def update(
    self,
    *,
    name: str,
    description: Optional[str] = None,
    code: Optional[str] = None,
    category: Optional[str] = None,
    bump: str = "patch",
) -> Skill:
    """Create a new version of an existing skill, preserving history.

    ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
    recorded in ``metadata.version``. The previous version is preserved in
    ``history(name)``.
    """
    versions = self._skills.get(name)
    if not versions:
        disk = self.registry.get(name)
        if disk is None:
            raise SkillError(f"cannot update unknown skill '{name}'")
        versions = [disk]
        self._skills[name] = versions

    previous = versions[-1]
    if code is None:
        code = previous.content
    else:
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

    new_version = self._next_version(previous.metadata.get("version"), bump)
    updated = Skill(
        name=name,
        description=description if description is not None else previous.description,
        content=code,
        category=category if category is not None else previous.category,
        metadata={
            **previous.metadata,
            "version": new_version,
            "previous_version": previous.metadata.get("version"),
            "sha256": self._sha256(code),
        },
        sha256=self._sha256(code),
    )
    versions.append(updated)
    return updated

_composed_body(name: str, callables: Sequence[str], combinator: str) -> str

Generate the source of the composed function for the given combinator.

Source code in src/ciel/runtime/skill_composition.py
def _composed_body(name: str, callables: Sequence[str], combinator: str) -> str:
    """Generate the source of the composed function for the given combinator."""
    names = list(callables)
    if combinator == "sequence":
        lines = [f"def {name}(*args, **kwargs):", "    _acc = " + f"{names[0]}(*args, **kwargs)"]
        for fn in names[1:]:
            lines.append(f"    _acc = {fn}(_acc)")
        lines.append("    return _acc")
        return "\n".join(lines)
    if combinator == "parallel":
        tuple_expr = "(" + ", ".join(names) + ")"
        return (
            f"def {name}(*args, **kwargs):\n"
            f"    return [_fn(*args, **kwargs) for _fn in {tuple_expr}]"
        )
    if combinator == "selector":
        tuple_expr = "(" + ", ".join(names) + ")"
        return (
            f"def {name}(*args, **kwargs):\n"
            f"    for _fn in {tuple_expr}:\n"
            f"        try:\n"
            f"            return _fn(*args, **kwargs)\n"
            f"        except Exception:\n"
            f"            continue\n"
            f"    raise RuntimeError(\"all composed skills failed\")"
        )
    raise SkillCompositionError(f"unknown combinator: {combinator!r}")

_main_callable(content: str, fallback_name: str) -> Optional[str]

Return the name of the primary callable defined in content.

Preference order: a top-level function matching fallback_name (usually the skill's own name), otherwise the first top-level function definition. Returns None if the code defines no functions.

Source code in src/ciel/runtime/skill_composition.py
def _main_callable(content: str, fallback_name: str) -> Optional[str]:
    """Return the name of the primary callable defined in ``content``.

    Preference order: a top-level function matching ``fallback_name`` (usually
    the skill's own name), otherwise the first top-level function definition.
    Returns ``None`` if the code defines no functions.
    """
    try:
        tree = ast.parse(content)
    except SyntaxError:
        return None
    funcs: List[str] = [n.name for n in tree.body if isinstance(n, ast.FunctionDef)]
    if not funcs:
        return None
    if fallback_name in funcs:
        return fallback_name
    return funcs[0]

Skill dataclass

Source code in src/ciel/runtime/skills.py
@dataclass
class Skill:
    name: str
    description: str
    content: str
    category: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    sha256: Optional[str] = None

SkillDocError

Bases: Exception

Raised when a skill's content cannot be parsed for documentation.

Source code in src/ciel/runtime/skill_doc.py
class SkillDocError(Exception):
    """Raised when a skill's content cannot be parsed for documentation."""

_parse_first_callable(content: str) -> Tuple[str, Optional[str], List[str]]

Parse content and return (name, docstring, args) of the first callable.

A "callable" is the first :class:ast.FunctionDef or :class:ast.AsyncFunctionDef in the module body. The args list contains the positional/keyword argument names (with * / ** markers for the variadic ones). Raises :class:SkillDocError for empty/bad/non-callable code.

Source code in src/ciel/runtime/skill_doc.py
def _parse_first_callable(content: str) -> Tuple[str, Optional[str], List[str]]:
    """Parse ``content`` and return ``(name, docstring, args)`` of the first callable.

    A "callable" is the first :class:`ast.FunctionDef` or
    :class:`ast.AsyncFunctionDef` in the module body. The ``args`` list contains
    the positional/keyword argument names (with ``*`` / ``**`` markers for the
    variadic ones). Raises :class:`SkillDocError` for empty/bad/non-callable code.
    """
    if not content or not content.strip():
        raise SkillDocError("skill content is empty")

    try:
        tree = ast.parse(content)
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillDocError(f"skill content has invalid syntax: {exc}") from exc

    node: Optional[ast.AST] = None
    for stmt in tree.body:
        if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
            node = stmt
            break

    if node is None:
        raise SkillDocError("skill content defines no callable to document")

    name = node.name  # type: ignore[attr-defined]
    docstring = ast.get_docstring(node)

    args: List[str] = []
    a = node.args  # type: ignore[attr-defined]
    for pos in a.posonlyargs:
        args.append(pos.arg)
    for arg in a.args:
        args.append(arg.arg)
    if a.vararg:
        args.append("*" + a.vararg.arg)
    for kw in a.kwonlyargs:
        args.append(kw.arg)
    if a.kwarg:
        args.append("**" + a.kwarg.arg)

    return name, docstring, args

_yaml_escape_scalar(value: str) -> str

Escape a string so it is safe as a single-line YAML double-quoted scalar.

Source code in src/ciel/runtime/skill_doc.py
def _yaml_escape_scalar(value: str) -> str:
    """Escape a string so it is safe as a single-line YAML double-quoted scalar."""
    return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")

generate_doc(skill: Skill) -> Dict[str, Any]

Build {"name", "description", "category"} from a skill's compiled code.

name and description are taken from the first callable defined in skill.content (its function name and docstring); description falls back to an empty string when the callable has no docstring. category is preserved verbatim from skill.category (which may be None).

Source code in src/ciel/runtime/skill_doc.py
def generate_doc(skill: Skill) -> Dict[str, Any]:
    """Build ``{"name", "description", "category"}`` from a skill's compiled code.

    ``name`` and ``description`` are taken from the *first* callable defined in
    ``skill.content`` (its function name and docstring); ``description`` falls
    back to an empty string when the callable has no docstring. ``category`` is
    preserved verbatim from ``skill.category`` (which may be ``None``).
    """
    name, docstring, _ = _parse_first_callable(skill.content)
    return {
        "name": name,
        "description": docstring or "",
        "category": skill.category,
    }

to_markdown(skill: Skill) -> str

Render a skill as a markdown string with YAML frontmatter + body.

The frontmatter carries name, description and (when present) category. The body repeats the title, the full docstring and the callable's argument signature, so the generated doc is useful on its own.

Source code in src/ciel/runtime/skill_doc.py
def to_markdown(skill: Skill) -> str:
    """Render a skill as a markdown string with YAML frontmatter + body.

    The frontmatter carries ``name``, ``description`` and (when present)
    ``category``. The body repeats the title, the full docstring and the
    callable's argument signature, so the generated doc is useful on its own.
    """
    name, docstring, args = _parse_first_callable(skill.content)
    category = skill.category

    frontmatter: List[str] = ["---"]
    frontmatter.append(f"name: {name}")
    frontmatter.append(f'description: "{_yaml_escape_scalar(docstring or "")}"')
    if category is not None:
        frontmatter.append(f"category: {category}")
    frontmatter.append("---")

    body: List[str] = [f"# {name}", ""]
    if docstring:
        body.append(docstring)
        body.append("")
    if category is not None:
        body.append(f"**Category:** {category}")
        body.append("")
    sig = f"{name}({', '.join(args)})" if args else f"{name}()"
    body.append("## Signature")
    body.append("")
    body.append(f"```python\n{sig}\n```")
    body.append("")

    return "\n".join(frontmatter) + "\n" + "\n".join(body)

Fase 12 Item 5 — Integración con ciel.Agent.

Este módulo conecta la :class:~ciel.runtime.skills_lib.SkillLibrary (Item 1 de Fase 12) con la fachada de alto nivel ciel.api (Fases 10/11) de forma 100% offline y sin romper la API existente del Agent.

Piezas que aporta:

  • @ciel.skill — decorador que registra una función como Skill en una :class:SkillLibrary global (singleton), validando su sintaxis en tiempo de definición (compile del source).
  • global_skill_library — la instancia singleton compartida por todo el proceso (mismo patrón que el registro de herramientas).
  • teach(agent, skill, ...) — helper que registra un skill verificado en un Agent ya construido, convirtiéndolo en un ToolFunction ejecutable a través del ToolRegistry existente.
  • install_agent_skill_support(Agent) — engancha Agent(skills=[...]) y Agent.teach(...) al Agent público sin reescribir api.py: se invoca al final de ciel/api.py (ver regla 2 del ítem).

No se cambia la firma pública de Agent existente: el soporte de skills se inyecta envuelto en __init__ y como método teach.

__all__ = ['global_skill_library', 'skill', 'teach', 'load_agent_skills', 'install_agent_skill_support'] module-attribute

global_skill_library: SkillLibrary = SkillLibrary() module-attribute

Skill dataclass

Source code in src/ciel/runtime/skills.py
@dataclass
class Skill:
    name: str
    description: str
    content: str
    category: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    sha256: Optional[str] = None

SkillError

Bases: Exception

Base error for skill library operations.

Source code in src/ciel/runtime/skills_lib.py
class SkillError(Exception):
    """Base error for skill library operations."""

SkillLibrary

Writable, in-memory skill store that wraps a :class:SkillRegistry.

The registry remains the source of truth for disk-loaded skills; the library layer adds creation, registration, update (with version bump) and removal of skills that live only in memory. Tenant isolation is supported via the optional tenant_id key on each stored :class:Skill.

Source code in src/ciel/runtime/skills_lib.py
class SkillLibrary:
    """Writable, in-memory skill store that wraps a :class:`SkillRegistry`.

    The registry remains the source of truth for disk-loaded skills; the
    library layer adds creation, registration, update (with version bump) and
    removal of skills that live only in memory. Tenant isolation is supported
    via the optional ``tenant_id`` key on each stored :class:`Skill`.
    """

    def __init__(self, registry: Optional[SkillRegistry] = None) -> None:
        self.registry = registry or SkillRegistry()
        # name -> list of Skill (newest last). We keep a list so update() can
        # preserve previous_versions for the evolution tree.
        self._skills: Dict[str, List[Skill]] = {}

    # -- backed by the passive registry (backward-compatible) -----------------

    def load_from_disk(self) -> List[Skill]:
        """Discover skills from the registry roots and index them in-memory."""
        found = self.registry.discover()
        for skill in found:
            self._skills.setdefault(skill.name, []).append(skill)
        return found

    # -- writable store --------------------------------------------------------

    def _sha256(self, content: str) -> str:
        import hashlib

        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    def create_from_code(
        self,
        *,
        name: str,
        description: str,
        code: str,
        category: Optional[str] = None,
        tenant_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Skill:
        """Compile ``code`` (syntax check) and store it as a new skill.

        Raises :class:`SkillError` if the code does not compile. The skill is
        NOT executed here — execution belongs to :class:`SkillVerifier`.
        """
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

        content = code
        skill = Skill(
            name=name,
            description=description,
            content=content,
            category=category,
            metadata={
                **(metadata or {}),
                **({"tenant_id": tenant_id} if tenant_id else {}),
                "sha256": self._sha256(content),
            },
            sha256=self._sha256(content),
        )
        self._skills.setdefault(name, []).append(skill)
        return skill

    def register(self, skill: Skill) -> Skill:
        """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
        if skill.sha256 is None:
            skill.sha256 = self._sha256(skill.content)
        self._skills.setdefault(skill.name, []).append(skill)
        return skill

    def get(self, name: str) -> Optional[Skill]:
        versions = self._skills.get(name)
        if not versions:
            # Fall back to the disk registry (backward-compat lookup).
            return self.registry.get(name)
        return versions[-1]

    def list_skills(self, *, category: Optional[str] = None, tenant_id: Optional[str] = None) -> List[Skill]:
        out: List[Skill] = []
        for versions in self._skills.values():
            latest = versions[-1]
            if category is not None and latest.category != category:
                continue
            if tenant_id is not None and latest.metadata.get("tenant_id") != tenant_id:
                continue
            out.append(latest)
        # Also include any registry-only skills not yet indexed in memory.
        for skill in self.registry.list_skills(category=category):
            if skill.name not in self._skills:
                if tenant_id is None or skill.metadata.get("tenant_id") == tenant_id:
                    out.append(skill)
        return out

    def history(self, name: str) -> List[Skill]:
        """Return every stored version of ``name`` (oldest first)."""
        return list(self._skills.get(name, []))

    def remove(self, name: str) -> bool:
        if name in self._skills:
            del self._skills[name]
            return True
        return False

    def update(
        self,
        *,
        name: str,
        description: Optional[str] = None,
        code: Optional[str] = None,
        category: Optional[str] = None,
        bump: str = "patch",
    ) -> Skill:
        """Create a new version of an existing skill, preserving history.

        ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
        recorded in ``metadata.version``. The previous version is preserved in
        ``history(name)``.
        """
        versions = self._skills.get(name)
        if not versions:
            disk = self.registry.get(name)
            if disk is None:
                raise SkillError(f"cannot update unknown skill '{name}'")
            versions = [disk]
            self._skills[name] = versions

        previous = versions[-1]
        if code is None:
            code = previous.content
        else:
            try:
                compile(code, f"<skill:{name}>", "exec")
            except SyntaxError as exc:  # noqa: BLE001
                raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

        new_version = self._next_version(previous.metadata.get("version"), bump)
        updated = Skill(
            name=name,
            description=description if description is not None else previous.description,
            content=code,
            category=category if category is not None else previous.category,
            metadata={
                **previous.metadata,
                "version": new_version,
                "previous_version": previous.metadata.get("version"),
                "sha256": self._sha256(code),
            },
            sha256=self._sha256(code),
        )
        versions.append(updated)
        return updated

    @staticmethod
    def _next_version(current: Optional[str], bump: str) -> str:
        major, minor, patch = (0, 0, 0)
        if current:
            parts = (current.split(".") + ["0", "0", "0"])[:3]
            try:
                major, minor, patch = (int(p) for p in parts)
            except ValueError:
                major, minor, patch = (0, 0, 0)
        if bump == "major":
            major, minor, patch = major + 1, 0, 0
        elif bump == "minor":
            minor, patch = minor + 1, 0
        else:
            patch += 1
        return f"{major}.{minor}.{patch}"

create_from_code(*, name: str, description: str, code: str, category: Optional[str] = None, tenant_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Skill

Compile code (syntax check) and store it as a new skill.

Raises :class:SkillError if the code does not compile. The skill is NOT executed here — execution belongs to :class:SkillVerifier.

Source code in src/ciel/runtime/skills_lib.py
def create_from_code(
    self,
    *,
    name: str,
    description: str,
    code: str,
    category: Optional[str] = None,
    tenant_id: Optional[str] = None,
    metadata: Optional[Dict[str, Any]] = None,
) -> Skill:
    """Compile ``code`` (syntax check) and store it as a new skill.

    Raises :class:`SkillError` if the code does not compile. The skill is
    NOT executed here — execution belongs to :class:`SkillVerifier`.
    """
    try:
        compile(code, f"<skill:{name}>", "exec")
    except SyntaxError as exc:  # noqa: BLE001
        raise SkillError(f"skill '{name}' has invalid syntax: {exc}") from exc

    content = code
    skill = Skill(
        name=name,
        description=description,
        content=content,
        category=category,
        metadata={
            **(metadata or {}),
            **({"tenant_id": tenant_id} if tenant_id else {}),
            "sha256": self._sha256(content),
        },
        sha256=self._sha256(content),
    )
    self._skills.setdefault(name, []).append(skill)
    return skill

history(name: str) -> List[Skill]

Return every stored version of name (oldest first).

Source code in src/ciel/runtime/skills_lib.py
def history(self, name: str) -> List[Skill]:
    """Return every stored version of ``name`` (oldest first)."""
    return list(self._skills.get(name, []))

load_from_disk() -> List[Skill]

Discover skills from the registry roots and index them in-memory.

Source code in src/ciel/runtime/skills_lib.py
def load_from_disk(self) -> List[Skill]:
    """Discover skills from the registry roots and index them in-memory."""
    found = self.registry.discover()
    for skill in found:
        self._skills.setdefault(skill.name, []).append(skill)
    return found

register(skill: Skill) -> Skill

Register an already-built :class:Skill (e.g. disk-loaded).

Source code in src/ciel/runtime/skills_lib.py
def register(self, skill: Skill) -> Skill:
    """Register an already-built :class:`Skill` (e.g. disk-loaded)."""
    if skill.sha256 is None:
        skill.sha256 = self._sha256(skill.content)
    self._skills.setdefault(skill.name, []).append(skill)
    return skill

update(*, name: str, description: Optional[str] = None, code: Optional[str] = None, category: Optional[str] = None, bump: str = 'patch') -> Skill

Create a new version of an existing skill, preserving history.

bump is one of major/minor/patch (semantic) and is recorded in metadata.version. The previous version is preserved in history(name).

Source code in src/ciel/runtime/skills_lib.py
def update(
    self,
    *,
    name: str,
    description: Optional[str] = None,
    code: Optional[str] = None,
    category: Optional[str] = None,
    bump: str = "patch",
) -> Skill:
    """Create a new version of an existing skill, preserving history.

    ``bump`` is one of ``major``/``minor``/``patch`` (semantic) and is
    recorded in ``metadata.version``. The previous version is preserved in
    ``history(name)``.
    """
    versions = self._skills.get(name)
    if not versions:
        disk = self.registry.get(name)
        if disk is None:
            raise SkillError(f"cannot update unknown skill '{name}'")
        versions = [disk]
        self._skills[name] = versions

    previous = versions[-1]
    if code is None:
        code = previous.content
    else:
        try:
            compile(code, f"<skill:{name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{name}' update has invalid syntax: {exc}") from exc

    new_version = self._next_version(previous.metadata.get("version"), bump)
    updated = Skill(
        name=name,
        description=description if description is not None else previous.description,
        content=code,
        category=category if category is not None else previous.category,
        metadata={
            **previous.metadata,
            "version": new_version,
            "previous_version": previous.metadata.get("version"),
            "sha256": self._sha256(code),
        },
        sha256=self._sha256(code),
    )
    versions.append(updated)
    return updated

SkillVerificationError

Bases: SkillError

Raised when a skill fails verification.

Source code in src/ciel/runtime/skills_lib.py
class SkillVerificationError(SkillError):
    """Raised when a skill fails verification."""

SkillVerifier

Offline verifier: syntax check + executable test cases.

A test case is a dict {"call": {...}, "expect": <value>}. The verifier executes the skill code in an isolated namespace, looks up a callable named after the skill (or the first callable defined), invokes it with call arguments and compares the result to expect.

Source code in src/ciel/runtime/skills_lib.py
class SkillVerifier:
    """Offline verifier: syntax check + executable test cases.

    A test case is a dict ``{"call": {...}, "expect": <value>}``. The verifier
    executes the skill code in an isolated namespace, looks up a callable named
    after the skill (or the first callable defined), invokes it with ``call``
    arguments and compares the result to ``expect``.
    """

    def __init__(self, library: Optional[SkillLibrary] = None) -> None:
        self.library = library

    def _resolve_callable(self, skill: Skill, namespace: Dict[str, Any]) -> Any:
        if skill.name in namespace and callable(namespace[skill.name]):
            return namespace[skill.name]
        callables = [v for v in namespace.values() if callable(v) and not v.__module__ == "builtins"]
        if not callables:
            raise SkillVerificationError(f"skill '{skill.name}' defines no callable to invoke")
        return callables[0]

    def verify(self, skill: Skill, *, test_cases: Sequence[Dict[str, Any]]) -> SkillVerificationResult:
        # 1) Syntax validation first.
        try:
            compile(skill.content, f"<skill:{skill.name}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            return SkillVerificationResult(
                passed=False,
                skill=skill.name,
                attempts=0,
                error=f"syntax error: {exc}",
                traceback=str(exc),
            )

        namespace: Dict[str, Any] = {}
        try:
            exec(skill.content, namespace)  # noqa: S102 — offline, trusted-by-construction
        except Exception as exc:  # noqa: BLE001
            return SkillVerificationResult(
                passed=False,
                skill=skill.name,
                attempts=0,
                error=f"load error: {type(exc).__name__}: {exc}",
                traceback=repr(exc),
            )

        fn = self._resolve_callable(skill, namespace)

        last_traceback: Optional[str] = None
        for attempt, case in enumerate(test_cases, start=1):
            call_args = case.get("call", {}) or {}
            expected = case.get("expect")
            try:
                got = fn(**call_args)
            except Exception as exc:  # noqa: BLE001
                last_traceback = repr(exc)
                return SkillVerificationResult(
                    passed=False,
                    skill=skill.name,
                    attempts=attempt,
                    error=f"case {attempt} raised {type(exc).__name__}: {exc}",
                    traceback=last_traceback,
                    expected=expected,
                )
            if got != expected:
                return SkillVerificationResult(
                    passed=False,
                    skill=skill.name,
                    attempts=attempt,
                    error=f"case {attempt}: expected {expected!r}, got {got!r}",
                    expected=expected,
                    got=got,
                )
        return SkillVerificationResult(
            passed=True,
            skill=skill.name,
            attempts=len(test_cases),
        )

    def verify_by_name(self, name: str, *, test_cases: Sequence[Dict[str, Any]]) -> SkillVerificationResult:
        if self.library is None:
            raise SkillVerificationError("SkillVerifier was built without a library; pass the skill directly")
        skill = self.library.get(name)
        if skill is None:
            raise SkillVerificationError(f"unknown skill '{name}'")
        return self.verify(skill, test_cases=test_cases)

Tool dataclass

Source code in src/ciel/runtime/tools.py
@dataclass
class Tool:
    spec: ToolSpec
    callable_: Any = None
    metadata: Dict[str, Any] = field(default_factory=dict)
    required_tenant: bool = False

_function_source(func: Any) -> str

Devuelve el source de func para validación de sintaxis.

Se elimina cualquier línea de decorador @skill / @ciel.skill para que el contenido sea ejecutable de forma aislada (p.ej. por el :class:SkillVerifier) sin depender del decorador en runtime.

Source code in src/ciel/runtime/skill_agent_integration.py
def _function_source(func: Any) -> str:
    """Devuelve el source de ``func`` para validación de sintaxis.

    Se elimina cualquier línea de decorador ``@skill`` / ``@ciel.skill`` para
    que el contenido sea ejecutable de forma aislada (p.ej. por el
    :class:`SkillVerifier`) sin depender del decorador en runtime.
    """
    import re
    import textwrap

    try:
        raw = inspect.getsource(func)
    except (OSError, TypeError):  # pragma: no cover - REPL/def sin archivo
        # Fallback: no tenemos source legible; delegamos la validación al
        # momento de convertir a ToolFunction (que compila el contenido).
        return f"def {getattr(func, '__name__', 'skill')}(*args, **kwargs):\n    pass\n"
    lines = textwrap.dedent(raw).splitlines()
    # Quita decoradores @skill / @ciel.skill que preceden a la definición.
    decor_re = re.compile(r"^\s*@(ciel\.)?skill(\(.*\))?\s*$")
    while lines and decor_re.match(lines[0]):
        lines.pop(0)
    return "\n".join(lines) + "\n"

_register_tool_function(agent: Any, tool_function: Any) -> None

Registra un ToolFunction en el registry del agente en runtime.

Source code in src/ciel/runtime/skill_agent_integration.py
def _register_tool_function(agent: Any, tool_function: Any) -> None:
    """Registra un ``ToolFunction`` en el registry del agente en runtime."""
    from ciel.api import ToolFunction

    ciel_tool = tool_function.as_tool if isinstance(tool_function, ToolFunction) else tool_function
    if not isinstance(ciel_tool, Tool):
        raise TypeError(
            f"skill tool must be a ToolFunction or Tool, got {type(tool_function).__name__}"
        )
    agent.registry.register_tool(agent.toolset, ciel_tool)
    # Refresca la lista de specs que el Agent entrega al modelo en cada run.
    agent._tool_specs.append(ciel_tool.spec)

_skill_to_tool_function(skill_obj: Skill) -> Any

Construye un ToolFunction ejecutable a partir de un Skill.

Prefiere la función original guardada en metadata["_callable"]; si no existe (skill creado solo desde código fuente) lo reconstruye ejecutando el contenido en un namespace aislado. Reusa ciel.api.tool para inferir el esquema JSON a partir de las type hints.

Source code in src/ciel/runtime/skill_agent_integration.py
def _skill_to_tool_function(skill_obj: Skill) -> Any:
    """Construye un ``ToolFunction`` ejecutable a partir de un ``Skill``.

    Prefiere la función original guardada en ``metadata["_callable"]``; si no
    existe (skill creado solo desde código fuente) lo reconstruye ejecutando el
    contenido en un namespace aislado. Reusa ``ciel.api.tool`` para inferir el
    esquema JSON a partir de las type hints.
    """
    fn = skill_obj.metadata.get("_callable")
    if fn is None:
        namespace: Dict[str, Any] = {}
        try:
            exec(compile(skill_obj.content, f"<skill:{skill_obj.name}>", "exec"), namespace)  # noqa: S102
        except Exception as exc:  # noqa: BLE001
            raise SkillError(f"skill '{skill_obj.name}' could not be loaded: {exc}") from exc
        fn = namespace.get(skill_obj.name)
        if fn is None or not callable(fn):
            callables = [
                v for v in namespace.values() if callable(v) and not inspect.isbuiltin(v)
            ]
            if not callables:
                raise SkillError(f"skill '{skill_obj.name}' defines no callable to expose as a tool")
            fn = callables[0]

    # Reusa la fachada pública de herramientas (inferencia de esquema + runtime
    # callable compatible). Import perezoso para evitar ciclos en tiempo de carga.
    from ciel.api import tool

    return tool(fn)

install_agent_skill_support(agent_cls: Any) -> Any

Inyecta skills=[...] en __init__ y teach como método.

No reescribe nada de api.py: envuelve __init__ para consumir el kwarg skills (y cargarlos como tools) y adjunta teach / load_skills como métodos de instancia.

Source code in src/ciel/runtime/skill_agent_integration.py
def install_agent_skill_support(agent_cls: Any) -> Any:
    """Inyecta ``skills=[...]`` en ``__init__`` y ``teach`` como método.

    No reescribe nada de ``api.py``: envuelve ``__init__`` para consumir el
    kwarg ``skills`` (y cargarlos como tools) y adjunta ``teach`` / ``load_skills``
    como métodos de instancia.
    """
    original_init = agent_cls.__init__

    @functools.wraps(original_init)
    def _patched_init(self: Any, *args: Any, **kwargs: Any) -> None:
        skills = kwargs.pop("skills", None)
        original_init(self, *args, **kwargs)
        if skills:
            load_agent_skills(self, list(skills))

    agent_cls.__init__ = _patched_init
    # teach(agent, skill) se enlaza como método de instancia:
    #   agent.teach(skill) -> teach(agent, skill)
    agent_cls.teach = teach  # type: ignore[attr-defined]
    agent_cls.load_skills = load_agent_skills  # type: ignore[attr-defined]
    return agent_cls

load_agent_skills(agent: Any, skill_names: Sequence[str]) -> List[Any]

Carga una lista de nombres de skills (desde la librería global) en el agente.

Source code in src/ciel/runtime/skill_agent_integration.py
def load_agent_skills(agent: Any, skill_names: Sequence[str]) -> List[Any]:
    """Carga una lista de nombres de skills (desde la librería global) en el agente."""
    registered: List[Any] = []
    for skill_name in skill_names:
        skill_obj = global_skill_library.get(skill_name)
        if skill_obj is None:
            raise SkillError(
                f"unknown skill '{skill_name}'; declare it first with @ciel.skill"
            )
        tool_function = _skill_to_tool_function(skill_obj)
        _register_tool_function(agent, tool_function)
        registered.append(tool_function)
    return registered

skill(fn: Optional[Any] = None, *, name: Optional[str] = None, description: Optional[str] = None, category: Optional[str] = None) -> Any

Decorador que registra fn como un Skill en la librería global.

Valida la sintaxis de la función (compile de su source) en tiempo de definición y guarda una referencia a la función original en metadata["_callable"] para poder exponerla luego como ToolFunction sin tener que re-ejecutar el código fuente.

Uso::

import ciel

@ciel.skill
def add(a: int, b: int) -> int:
    "Suma dos enteros."
    return a + b

# Disponible de inmediato en la librería singleton:
ciel.runtime.skill_agent_integration.global_skill_library.get("add")
Source code in src/ciel/runtime/skill_agent_integration.py
def skill(
    fn: Optional[Any] = None,
    *,
    name: Optional[str] = None,
    description: Optional[str] = None,
    category: Optional[str] = None,
) -> Any:
    """Decorador que registra ``fn`` como un ``Skill`` en la librería global.

    Valida la sintaxis de la función (``compile`` de su source) en tiempo de
    definición y guarda una referencia a la función original en
    ``metadata["_callable"]`` para poder exponerla luego como ``ToolFunction``
    sin tener que re-ejecutar el código fuente.

    Uso::

        import ciel

        @ciel.skill
        def add(a: int, b: int) -> int:
            \"Suma dos enteros.\"
            return a + b

        # Disponible de inmediato en la librería singleton:
        ciel.runtime.skill_agent_integration.global_skill_library.get("add")
    """

    def _decorate(func: Any) -> Any:
        sname = name or getattr(func, "__name__", "skill")
        sdesc = description or (inspect.getdoc(func) or "").strip() or sname
        source = _function_source(func)
        # Validación de sintaxis (offline, sin ejecutar).
        try:
            compile(source, f"<skill:{sname}>", "exec")
        except SyntaxError as exc:  # noqa: BLE001
            raise SkillError(f"skill '{sname}' has invalid syntax: {exc}") from exc

        skill_obj = Skill(
            name=sname,
            description=sdesc,
            content=source,
            category=category,
            metadata={"_callable": func, "source": source},
        )
        global_skill_library.register(skill_obj)
        # Marca la función para inspección/debug.
        try:
            func._ciel_skill = sname  # type: ignore[attr-defined]
        except AttributeError:  # pragma: no cover - builtins/non-settable
            pass
        return func

    if fn is not None:
        return _decorate(fn)
    return _decorate

teach(agent: Any, skill_obj: Skill, *, test_cases: Optional[Sequence[Dict[str, Any]]] = None, verify: bool = True) -> Any

Registra skill_obj como herramienta ejecutable en agent.

Si test_cases se provee y verify=True, el skill pasa primero por el :class:SkillVerifier offline; se lanza :class:SkillVerificationError si no pasa. Devuelve el ToolFunction registrado.

Source code in src/ciel/runtime/skill_agent_integration.py
def teach(
    agent: Any,
    skill_obj: Skill,
    *,
    test_cases: Optional[Sequence[Dict[str, Any]]] = None,
    verify: bool = True,
) -> Any:
    """Registra ``skill_obj`` como herramienta ejecutable en ``agent``.

    Si ``test_cases`` se provee y ``verify=True``, el skill pasa primero por el
    :class:`SkillVerifier` offline; se lanza :class:`SkillVerificationError` si
    no pasa. Devuelve el ``ToolFunction`` registrado.
    """
    if verify and test_cases:
        verifier = SkillVerifier(library=global_skill_library)
        result = verifier.verify(skill_obj, test_cases=list(test_cases))
        if not result.passed:
            raise SkillVerificationError(
                f"skill '{skill_obj.name}' failed verification: {result.error}"
            )
    tool_function = _skill_to_tool_function(skill_obj)
    _register_tool_function(agent, tool_function)
    return tool_function

SkillMetrics

In-memory performance metrics for skills, optionally isolated by tenant.

Usage::

metrics = SkillMetrics()
metrics.record_usage("lib", "add", success=True, latency_ms=12.5)
print(metrics.metrics("add"))
# {'calls': 1, 'successes': 1, 'failures': 0,
#  'success_rate': 1.0, 'avg_latency_ms': 12.5}

The lib argument records which skill library/source emitted the call (kept for traceability) while name is the key the metrics are stored under. When tenant_id is supplied, metrics are kept in a separate namespace so two tenants with the same skill name never share counters.

Source code in src/ciel/runtime/skill_metrics.py
class SkillMetrics:
    """In-memory performance metrics for skills, optionally isolated by tenant.

    Usage::

        metrics = SkillMetrics()
        metrics.record_usage("lib", "add", success=True, latency_ms=12.5)
        print(metrics.metrics("add"))
        # {'calls': 1, 'successes': 1, 'failures': 0,
        #  'success_rate': 1.0, 'avg_latency_ms': 12.5}

    The ``lib`` argument records which skill library/source emitted the call
    (kept for traceability) while ``name`` is the key the metrics are stored
    under. When ``tenant_id`` is supplied, metrics are kept in a separate
    namespace so two tenants with the same skill name never share counters.
    """

    def __init__(self) -> None:
        # tenant_id (or "" for the global scope) -> name -> _SkillStat
        self._store: Dict[str, Dict[str, _SkillStat]] = {}

    def _scope(self, tenant_id: Optional[str]) -> Dict[str, _SkillStat]:
        key = tenant_id if tenant_id is not None else ""
        return self._store.setdefault(key, {})

    def record_usage(
        self,
        lib: str,
        name: str,
        success: bool,
        latency_ms: float = 0.0,
        tenant_id: Optional[str] = None,
    ) -> None:
        """Record one skill invocation.

        ``lib`` identifies the originating library/source (stored for
        traceability but does not affect the metric key). ``name`` is the
        skill name the metrics are aggregated under. ``success`` marks the
        outcome and ``latency_ms`` is the observed latency. ``tenant_id``
        optionally isolates the record into a tenant-specific namespace.
        """
        scope = self._scope(tenant_id)
        stat = scope.get(name)
        if stat is None:
            stat = _SkillStat()
            scope[name] = stat
        stat.record(success=success, latency_ms=latency_ms)

    def metrics(self, name: str, tenant_id: Optional[str] = None) -> Dict[str, float]:
        """Return aggregated metrics for ``name`` as a dict.

        Returns ``{calls, successes, failures, success_rate, avg_latency_ms}``.
        Unknown skills return all-zero counters (success_rate 0.0) rather than
        raising, so callers can read metrics before any usage has been recorded.
        """
        scope = self._scope(tenant_id)
        stat = scope.get(name)
        if stat is None:
            return {
                "calls": 0,
                "successes": 0,
                "failures": 0,
                "success_rate": 0.0,
                "avg_latency_ms": 0.0,
            }
        return stat.as_dict()

    def reset(self, *, tenant_id: Optional[str] = None) -> None:
        """Clear recorded metrics.

        With ``tenant_id`` only that tenant's namespace is cleared; with no
        tenant_id the global (non-tenant) namespace is cleared.
        """
        key = tenant_id if tenant_id is not None else ""
        self._store[key] = {}

metrics(name: str, tenant_id: Optional[str] = None) -> Dict[str, float]

Return aggregated metrics for name as a dict.

Returns {calls, successes, failures, success_rate, avg_latency_ms}. Unknown skills return all-zero counters (success_rate 0.0) rather than raising, so callers can read metrics before any usage has been recorded.

Source code in src/ciel/runtime/skill_metrics.py
def metrics(self, name: str, tenant_id: Optional[str] = None) -> Dict[str, float]:
    """Return aggregated metrics for ``name`` as a dict.

    Returns ``{calls, successes, failures, success_rate, avg_latency_ms}``.
    Unknown skills return all-zero counters (success_rate 0.0) rather than
    raising, so callers can read metrics before any usage has been recorded.
    """
    scope = self._scope(tenant_id)
    stat = scope.get(name)
    if stat is None:
        return {
            "calls": 0,
            "successes": 0,
            "failures": 0,
            "success_rate": 0.0,
            "avg_latency_ms": 0.0,
        }
    return stat.as_dict()

record_usage(lib: str, name: str, success: bool, latency_ms: float = 0.0, tenant_id: Optional[str] = None) -> None

Record one skill invocation.

lib identifies the originating library/source (stored for traceability but does not affect the metric key). name is the skill name the metrics are aggregated under. success marks the outcome and latency_ms is the observed latency. tenant_id optionally isolates the record into a tenant-specific namespace.

Source code in src/ciel/runtime/skill_metrics.py
def record_usage(
    self,
    lib: str,
    name: str,
    success: bool,
    latency_ms: float = 0.0,
    tenant_id: Optional[str] = None,
) -> None:
    """Record one skill invocation.

    ``lib`` identifies the originating library/source (stored for
    traceability but does not affect the metric key). ``name`` is the
    skill name the metrics are aggregated under. ``success`` marks the
    outcome and ``latency_ms`` is the observed latency. ``tenant_id``
    optionally isolates the record into a tenant-specific namespace.
    """
    scope = self._scope(tenant_id)
    stat = scope.get(name)
    if stat is None:
        stat = _SkillStat()
        scope[name] = stat
    stat.record(success=success, latency_ms=latency_ms)

reset(*, tenant_id: Optional[str] = None) -> None

Clear recorded metrics.

With tenant_id only that tenant's namespace is cleared; with no tenant_id the global (non-tenant) namespace is cleared.

Source code in src/ciel/runtime/skill_metrics.py
def reset(self, *, tenant_id: Optional[str] = None) -> None:
    """Clear recorded metrics.

    With ``tenant_id`` only that tenant's namespace is cleared; with no
    tenant_id the global (non-tenant) namespace is cleared.
    """
    key = tenant_id if tenant_id is not None else ""
    self._store[key] = {}

_SkillStat dataclass

Accumulated metrics for a single skill (within one tenant scope).

Source code in src/ciel/runtime/skill_metrics.py
@dataclass
class _SkillStat:
    """Accumulated metrics for a single skill (within one tenant scope)."""

    calls: int = 0
    successes: int = 0
    failures: int = 0
    _latency_sum_ms: float = 0.0

    def record(self, *, success: bool, latency_ms: float) -> None:
        self.calls += 1
        if success:
            self.successes += 1
        else:
            self.failures += 1
        self._latency_sum_ms += float(latency_ms)

    @property
    def success_rate(self) -> float:
        if self.calls == 0:
            return 0.0
        return self.successes / self.calls

    @property
    def avg_latency_ms(self) -> float:
        if self.calls == 0:
            return 0.0
        return self._latency_sum_ms / self.calls

    def as_dict(self) -> Dict[str, float]:
        return {
            "calls": self.calls,
            "successes": self.successes,
            "failures": self.failures,
            "success_rate": self.success_rate,
            "avg_latency_ms": self.avg_latency_ms,
        }