mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-21 17:08:10 +00:00
Compare commits
7 Commits
matcha/sha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b6594669 | ||
|
|
d3e20900e8 | ||
|
|
81c21e3166 | ||
|
|
b4b285764c | ||
|
|
418afd29e7 | ||
|
|
7cc1a7bb41 | ||
|
|
09ffe87fbb |
36
.github/workflows/vulnerability-scan.yml
vendored
36
.github/workflows/vulnerability-scan.yml
vendored
@@ -46,11 +46,39 @@ jobs:
|
||||
- name: Run pip-audit
|
||||
run: |
|
||||
uv run pip-audit --desc --aliases --skip-editable --format json --output pip-audit-report.json \
|
||||
--ignore-vuln CVE-2026-3219 \
|
||||
--ignore-vuln GHSA-r374-rxx8-8654
|
||||
--ignore-vuln PYSEC-2024-277 \
|
||||
--ignore-vuln PYSEC-2026-89 \
|
||||
--ignore-vuln PYSEC-2026-97 \
|
||||
--ignore-vuln PYSEC-2025-148 \
|
||||
--ignore-vuln PYSEC-2025-183 \
|
||||
--ignore-vuln PYSEC-2025-189 \
|
||||
--ignore-vuln PYSEC-2025-190 \
|
||||
--ignore-vuln PYSEC-2025-191 \
|
||||
--ignore-vuln PYSEC-2025-192 \
|
||||
--ignore-vuln PYSEC-2025-193 \
|
||||
--ignore-vuln PYSEC-2025-194 \
|
||||
--ignore-vuln PYSEC-2025-195 \
|
||||
--ignore-vuln PYSEC-2025-196 \
|
||||
--ignore-vuln PYSEC-2025-197 \
|
||||
--ignore-vuln PYSEC-2025-210 \
|
||||
--ignore-vuln PYSEC-2026-139 \
|
||||
--ignore-vuln PYSEC-2025-211 \
|
||||
--ignore-vuln PYSEC-2025-212 \
|
||||
--ignore-vuln PYSEC-2025-213 \
|
||||
--ignore-vuln PYSEC-2025-214 \
|
||||
--ignore-vuln PYSEC-2025-215 \
|
||||
--ignore-vuln PYSEC-2025-216 \
|
||||
--ignore-vuln PYSEC-2025-217 \
|
||||
--ignore-vuln PYSEC-2025-218
|
||||
# Ignored CVEs:
|
||||
# CVE-2026-3219 - pip 26.0.1 (GHSA-58qw-9mgm-455v): no fix available, archive handling issue
|
||||
# GHSA-r374-rxx8-8654 - paramiko 4.0.0 (SHA-1 in rsakey.py): no fix available; transitive via composio-core
|
||||
# PYSEC-2024-277 - joblib 1.5.3: disputed; NumpyArrayWrapper only used with trusted caches
|
||||
# PYSEC-2026-89 - markdown 3.10.2: DoS via malformed HTML; fix 3.8.1 — already past, advisory range is stale
|
||||
# PYSEC-2026-97 - nltk 3.9.4: arbitrary file read in filestring(); no fix available
|
||||
# PYSEC-2025-148 - onnx 1.21.0: path traversal in save_external_data; no fix available
|
||||
# PYSEC-2025-183 - pyjwt 2.12.1: disputed weak-encryption claim; key length is application-chosen
|
||||
# PYSEC-2025-189..197 - torch 2.11.0: memory-corruption/DoS in functions only reachable via untrusted models; no fix available
|
||||
# PYSEC-2025-210, PYSEC-2026-139 - torch 2.11.0: profiler/deserialization issues; no fix available
|
||||
# PYSEC-2025-211..218 - transformers 5.5.4: deserialization/code injection via malicious model checkpoints; no fix available
|
||||
continue-on-error: true
|
||||
|
||||
- name: Display results
|
||||
|
||||
@@ -4,6 +4,31 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="21 مايو 2026">
|
||||
## v1.14.6a1
|
||||
|
||||
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.6a1)
|
||||
|
||||
## ما الذي تغير
|
||||
|
||||
### الميزات
|
||||
- إضافة مستودع المهارات مع التسجيل، التخزين المؤقت، واجهة سطر الأوامر، وتكامل SDK
|
||||
- توليد ملاحظات إصدار مصنفة للمؤسسات
|
||||
|
||||
### إصلاحات الأخطاء
|
||||
- تعزيز تسلسل حالة وقت التشغيل عبر حقول الكيان
|
||||
- تحديث idna إلى 3.15 لمعالجة مشكلة الأمان GHSA-65pc-fj4g-8rjx
|
||||
- إزالة تعبيرات JSX `{" "}` التي تعطل عرض `<Steps>`
|
||||
|
||||
### الوثائق
|
||||
- تحديث سجل التغييرات والإصدار لـ v1.14.5
|
||||
|
||||
## المساهمون
|
||||
|
||||
@akaKuruma, @alex-clawd, @greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="19 مايو 2026">
|
||||
## v1.14.5
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="May 21, 2026">
|
||||
## v1.14.6a1
|
||||
|
||||
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.6a1)
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
- Add Skills Repository with registry, cache, CLI, and SDK integration
|
||||
- Generate categorized release notes for enterprise
|
||||
|
||||
### Bug Fixes
|
||||
- Harden RuntimeState serialization across entity fields
|
||||
- Bump idna to 3.15 to address security issue GHSA-65pc-fj4g-8rjx
|
||||
- Remove `{" "}` JSX expressions breaking `<Steps>` render
|
||||
|
||||
### Documentation
|
||||
- Update changelog and version for v1.14.5
|
||||
|
||||
## Contributors
|
||||
|
||||
@akaKuruma, @alex-clawd, @greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 19, 2026">
|
||||
## v1.14.5
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="2026년 5월 21일">
|
||||
## v1.14.6a1
|
||||
|
||||
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.6a1)
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### 기능
|
||||
- 레지스트리, 캐시, CLI 및 SDK 통합이 포함된 기술 저장소 추가
|
||||
- 기업용으로 분류된 릴리스 노트 생성
|
||||
|
||||
### 버그 수정
|
||||
- 엔티티 필드 전반에 걸쳐 RuntimeState 직렬화 강화
|
||||
- 보안 문제 GHSA-65pc-fj4g-8rjx를 해결하기 위해 idna를 3.15로 업데이트
|
||||
- `<Steps>` 렌더링을 방해하는 `{" "}` JSX 표현식 제거
|
||||
|
||||
### 문서
|
||||
- v1.14.5에 대한 변경 로그 및 버전 업데이트
|
||||
|
||||
## 기여자
|
||||
|
||||
@akaKuruma, @alex-clawd, @greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026년 5월 19일">
|
||||
## v1.14.5
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
|
||||
icon: "clock"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="21 mai 2026">
|
||||
## v1.14.6a1
|
||||
|
||||
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.6a1)
|
||||
|
||||
## O que Mudou
|
||||
|
||||
### Recursos
|
||||
- Adicionar Repositório de Habilidades com registro, cache, CLI e integração SDK
|
||||
- Gerar notas de versão categorizadas para empresas
|
||||
|
||||
### Correções de Bugs
|
||||
- Fortalecer a serialização de RuntimeState entre os campos da entidade
|
||||
- Atualizar idna para 3.15 para resolver problema de segurança GHSA-65pc-fj4g-8rjx
|
||||
- Remover expressões JSX `{" "}` que quebram a renderização de `<Steps>`
|
||||
|
||||
### Documentação
|
||||
- Atualizar changelog e versão para v1.14.5
|
||||
|
||||
## Contribuidores
|
||||
|
||||
@akaKuruma, @alex-clawd, @greysonlalonde
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="19 mai 2026">
|
||||
## v1.14.5
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5",
|
||||
"crewai-core==1.14.6a1",
|
||||
"click~=8.1.7",
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"pydantic-settings~=2.10.1",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
@@ -26,6 +26,7 @@ from crewai_cli.replay_from_task import replay_task_command
|
||||
from crewai_cli.reset_memories_command import reset_memories_command
|
||||
from crewai_cli.run_crew import run_crew
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
from crewai_cli.task_outputs import load_task_outputs
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
from crewai_cli.train_crew import train_crew
|
||||
@@ -546,6 +547,56 @@ def tool_publish(is_public: bool, force: bool) -> None:
|
||||
tool_cmd.publish(is_public, force)
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def skill() -> None:
|
||||
"""Skill Repository related commands."""
|
||||
|
||||
|
||||
@skill.command(name="create")
|
||||
@click.argument("name")
|
||||
@click.option(
|
||||
"--no-project",
|
||||
"in_project",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
flag_value=False,
|
||||
help="Create skill in current dir instead of ./skills/",
|
||||
)
|
||||
def skill_create(name: str, in_project: bool) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.create(name, in_project=in_project)
|
||||
|
||||
|
||||
@skill.command(name="install")
|
||||
@click.argument("ref")
|
||||
def skill_install(ref: str) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.install(ref)
|
||||
|
||||
|
||||
@skill.command(name="publish")
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
show_default=True,
|
||||
help="Skip git-state validation.",
|
||||
)
|
||||
@click.option("--public", "is_public", flag_value=True, default=False)
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
|
||||
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.publish(is_public, org=org, force=force)
|
||||
|
||||
|
||||
@skill.command(name="list")
|
||||
def skill_list() -> None:
|
||||
"""List locally installed skills."""
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.list_cached()
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def template() -> None:
|
||||
"""Browse and install project templates."""
|
||||
|
||||
@@ -40,7 +40,7 @@ class Repository:
|
||||
encoding="utf-8",
|
||||
).strip()
|
||||
|
||||
@cached_property # noqa: B019
|
||||
@cached_property
|
||||
def is_git_repo(self) -> bool:
|
||||
"""Check if the current directory is a git repository."""
|
||||
try:
|
||||
|
||||
0
lib/cli/src/crewai_cli/skills/__init__.py
Normal file
0
lib/cli/src/crewai_cli/skills/__init__.py
Normal file
415
lib/cli/src/crewai_cli/skills/main.py
Normal file
415
lib/cli/src/crewai_cli/skills/main.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Skill Repository CLI commands for CrewAI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from crewai_cli.command import BaseCommand, PlusAPIMixin
|
||||
from crewai_cli.config import Settings
|
||||
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
|
||||
|
||||
console = Console()
|
||||
|
||||
_SKILL_MD_TEMPLATE = """\
|
||||
---
|
||||
name: {name}
|
||||
version: 0.1.0
|
||||
description: |
|
||||
A short description of what this skill does.
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
Describe the skill behaviour here. This section is shown to the agent at activation time.
|
||||
"""
|
||||
|
||||
|
||||
class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
"""Skill Repository related operations for CrewAI projects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
BaseCommand.__init__(self)
|
||||
PlusAPIMixin.__init__(self, telemetry=self._telemetry)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# create
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create(self, name: str, in_project: bool = True) -> None:
|
||||
"""Scaffold a new skill directory.
|
||||
|
||||
If pyproject.toml is present (crew project), creates ./skills/{name}/.
|
||||
Otherwise creates ./{name}/.
|
||||
"""
|
||||
if in_project and os.path.isfile("pyproject.toml"):
|
||||
skill_dir = Path("skills") / name
|
||||
else:
|
||||
skill_dir = Path(name)
|
||||
|
||||
if skill_dir.exists():
|
||||
console.print(f"[red]Directory {skill_dir} already exists.[/red]")
|
||||
raise SystemExit(1)
|
||||
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "scripts").mkdir()
|
||||
(skill_dir / "references").mkdir()
|
||||
(skill_dir / "assets").mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name))
|
||||
|
||||
console.print(
|
||||
f"[green]Created skill [bold]{name}[/bold] at [bold]{skill_dir}[/bold].[/green]"
|
||||
)
|
||||
console.print(f"Edit [bold]{skill_md}[/bold] to define the skill instructions.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# install
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def install(self, ref: str) -> None:
|
||||
"""Download and install a registry skill.
|
||||
|
||||
Format: @org/name
|
||||
|
||||
Inside a crew project (pyproject.toml present): installs to ./skills/{name}/
|
||||
Outside a project: installs to ~/.crewai/skills/{org}/{name}/
|
||||
"""
|
||||
if not ref.startswith("@"):
|
||||
console.print(
|
||||
"[red]Invalid skill reference. Use the format @org/name.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
without_at = ref[1:]
|
||||
if without_at.count("/") != 1:
|
||||
console.print(
|
||||
"[red]Invalid skill reference. Use the format @org/name.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
org, name = without_at.split("/", 1)
|
||||
if (
|
||||
not org
|
||||
or not name
|
||||
or org.startswith(".")
|
||||
or name.startswith(".")
|
||||
or len(Path(org).parts) != 1
|
||||
or len(Path(name).parts) != 1
|
||||
):
|
||||
console.print(
|
||||
"[red]Invalid skill reference: org and name must be single, "
|
||||
"non-empty path segments (no slashes, no '..').[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self._print_current_organization()
|
||||
console.print(f"[bold blue]Downloading skill {ref}...[/bold blue]")
|
||||
|
||||
get_response = self.plus_api_client.get_skill(org, name)
|
||||
|
||||
if get_response.status_code == 404:
|
||||
console.print(
|
||||
f"[red]Skill {ref} not found. Ensure it has been published and you have access.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if get_response.status_code != 200:
|
||||
console.print(
|
||||
f"[red]Failed to download skill {ref}: {get_response.status_code}[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
data = get_response.json()
|
||||
version = data.get("latest_version") or data.get("version")
|
||||
|
||||
download_url = data.get("download_url")
|
||||
if download_url:
|
||||
import httpx
|
||||
|
||||
dl_response = httpx.get(download_url, follow_redirects=True)
|
||||
dl_response.raise_for_status()
|
||||
archive_bytes = dl_response.content
|
||||
else:
|
||||
encoded = data.get("file", "")
|
||||
if "," in encoded:
|
||||
encoded = encoded.split(",", 1)[1]
|
||||
archive_bytes = base64.b64decode(encoded)
|
||||
|
||||
in_project = os.path.isfile("pyproject.toml")
|
||||
if in_project:
|
||||
dest = Path("skills") / name
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
self._unpack_archive(archive_bytes, dest)
|
||||
console.print(
|
||||
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to [bold]{dest}[/bold].[/green]"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
cache = SkillCacheManager()
|
||||
cache.store(org, name, version, archive_bytes)
|
||||
except ImportError:
|
||||
# Fallback if SDK not installed — write directly
|
||||
cache_dir = Path.home() / ".crewai" / "skills" / org / name
|
||||
if cache_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._unpack_archive(archive_bytes, cache_dir)
|
||||
# Write metadata so `crewai skill list` can discover it
|
||||
from datetime import datetime, timezone
|
||||
|
||||
meta = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
(cache_dir / ".crewai_meta.json").write_text(json.dumps(meta, indent=2))
|
||||
console.print(
|
||||
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to global cache.[/green]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# publish
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def publish(self, is_public: bool, org: str | None, force: bool = False) -> None:
|
||||
"""Publish the skill in the current directory to the registry."""
|
||||
skill_md = Path("SKILL.md")
|
||||
if not skill_md.exists():
|
||||
console.print(
|
||||
"[red]No SKILL.md found in current directory. "
|
||||
"Run this command from inside a skill directory.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Parse frontmatter to extract name + version
|
||||
try:
|
||||
frontmatter = self._parse_frontmatter(skill_md.read_text())
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Failed to parse SKILL.md frontmatter: {exc}[/red]")
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
name = frontmatter.get("name")
|
||||
version = frontmatter.get("version")
|
||||
description = frontmatter.get("description")
|
||||
|
||||
if not name:
|
||||
console.print(
|
||||
"[red]SKILL.md frontmatter must include a 'name' field.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not version:
|
||||
console.print(
|
||||
"[red]SKILL.md frontmatter must include a 'version' field before publishing.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
settings = Settings()
|
||||
effective_org = org or settings.org_name
|
||||
if not effective_org:
|
||||
console.print(
|
||||
"[red]No organisation set. Run `crewai org switch <org_id>` first, "
|
||||
"or pass --org.[/red]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self._print_current_organization()
|
||||
console.print(
|
||||
f"[bold blue]Publishing skill [bold]{name}[/bold] v{version} to {effective_org}...[/bold blue]"
|
||||
)
|
||||
|
||||
archive_bytes = self._build_skill_tarball()
|
||||
encoded_file = "data:application/x-gzip;base64," + base64.b64encode(
|
||||
archive_bytes
|
||||
).decode("utf-8")
|
||||
|
||||
response = self.plus_api_client.publish_skill(
|
||||
org=effective_org,
|
||||
name=name,
|
||||
version=version,
|
||||
is_public=is_public,
|
||||
description=description,
|
||||
encoded_file=encoded_file,
|
||||
)
|
||||
|
||||
self._validate_response(response)
|
||||
|
||||
base_url = settings.enterprise_base_url or DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
console.print(
|
||||
f"[green]Published [bold]{effective_org}/{name}[/bold] v{version}.\n\n"
|
||||
"Security checks are running in the background. "
|
||||
"Your skill will be available once checks complete.\n"
|
||||
f"Monitor status at: {base_url}/crewai_plus/skills/{effective_org}/{name}[/green]"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# list_cached
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_cached(self) -> None:
|
||||
"""Show locally installed skills."""
|
||||
table = Table(title="Installed Skills", show_lines=True)
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Ref")
|
||||
table.add_column("Version")
|
||||
table.add_column("Path")
|
||||
|
||||
# Project-local ./skills/
|
||||
local_skills_dir = Path("skills")
|
||||
if local_skills_dir.is_dir():
|
||||
for skill_dir in sorted(local_skills_dir.iterdir()):
|
||||
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
||||
version = self._read_version(skill_dir / "SKILL.md")
|
||||
table.add_row(
|
||||
"project",
|
||||
skill_dir.name,
|
||||
version or "-",
|
||||
str(skill_dir),
|
||||
)
|
||||
|
||||
# Global cache
|
||||
cache_root = Path.home() / ".crewai" / "skills"
|
||||
if cache_root.exists():
|
||||
for org_dir in sorted(cache_root.iterdir()):
|
||||
if not org_dir.is_dir():
|
||||
continue
|
||||
for skill_dir in sorted(org_dir.iterdir()):
|
||||
meta_file = skill_dir / ".crewai_meta.json"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
table.add_row(
|
||||
"cache",
|
||||
f"@{meta['org']}/{meta['name']}",
|
||||
meta.get("version") or "-",
|
||||
str(skill_dir),
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
console.print(
|
||||
f"[yellow]Warning: skipping malformed cache entry at {meta_file}[/yellow]"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _print_current_organization(self) -> None:
|
||||
settings = Settings()
|
||||
if settings.org_uuid:
|
||||
console.print(
|
||||
f"Current organization: {settings.org_name} ({settings.org_uuid})",
|
||||
style="bold blue",
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"No organization currently set. We recommend setting one before using: "
|
||||
"`crewai org switch <org_id>` command.",
|
||||
style="yellow",
|
||||
)
|
||||
|
||||
def _unpack_archive(self, archive_bytes: bytes, dest: Path) -> None:
|
||||
"""Unpack a .tar.gz or .zip archive into dest."""
|
||||
# Try tar first, then zip
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf:
|
||||
try:
|
||||
tf.extractall(dest, filter="data")
|
||||
except TypeError:
|
||||
_safe_extractall(tf, dest)
|
||||
return
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
|
||||
# Fallback: zip
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
|
||||
_safe_extract_zip(zf, dest)
|
||||
|
||||
def _build_skill_tarball(self) -> bytes:
|
||||
"""Build an in-memory .tar.gz of SKILL.md + scripts/ + references/ + assets/."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||
tf.add("SKILL.md")
|
||||
for folder in ("scripts", "references", "assets"):
|
||||
folder_path = Path(folder)
|
||||
if folder_path.is_dir():
|
||||
for fpath in sorted(folder_path.rglob("*")):
|
||||
if fpath.is_file():
|
||||
tf.add(str(fpath))
|
||||
return buf.getvalue()
|
||||
|
||||
def _parse_frontmatter(self, content: str) -> dict[str, str]:
|
||||
"""Extract YAML frontmatter fields from a SKILL.md string.
|
||||
|
||||
Reuses crewai.skills.parser when available, with a minimal
|
||||
fallback for environments where the full SDK isn't installed.
|
||||
"""
|
||||
try:
|
||||
from crewai.skills.parser import parse_frontmatter
|
||||
|
||||
fm_dict, _ = parse_frontmatter(content)
|
||||
return fm_dict
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: minimal YAML parsing without SDK dependency
|
||||
import re
|
||||
|
||||
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError("No YAML frontmatter block found")
|
||||
try:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_load(match.group(1)) or {}
|
||||
except ImportError:
|
||||
result: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
result[key.strip()] = value.strip()
|
||||
return result
|
||||
|
||||
def _read_version(self, skill_md: Path) -> str | None:
|
||||
"""Read the version field from a SKILL.md file, or None."""
|
||||
try:
|
||||
fm = self._parse_frontmatter(skill_md.read_text())
|
||||
return fm.get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe ZIP extraction."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (dest / member).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member!r}")
|
||||
zf.extractall(dest) # noqa: S202
|
||||
0
lib/cli/tests/skills/__init__.py
Normal file
0
lib/cli/tests/skills/__init__.py
Normal file
205
lib/cli/tests/skills/test_main.py
Normal file
205
lib/cli/tests/skills/test_main.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for SkillCommand CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.shared.token_manager import TokenManager
|
||||
|
||||
|
||||
@contextmanager
|
||||
def in_temp_dir():
|
||||
original = os.getcwd()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
os.chdir(td)
|
||||
try:
|
||||
yield td
|
||||
finally:
|
||||
os.chdir(original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_command():
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
with patch.object(
|
||||
TokenManager, "_get_secure_storage_path", return_value=Path(temp_dir)
|
||||
):
|
||||
TokenManager().save_tokens(
|
||||
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
|
||||
)
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
cmd = SkillCommand()
|
||||
yield cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillCreate:
|
||||
def test_create_in_project(self, skill_command, tmp_path):
|
||||
with in_temp_dir():
|
||||
# Simulate being inside a project
|
||||
Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n")
|
||||
skill_command.create("my-skill")
|
||||
assert Path("skills/my-skill/SKILL.md").exists()
|
||||
assert Path("skills/my-skill/scripts").is_dir()
|
||||
assert Path("skills/my-skill/references").is_dir()
|
||||
assert Path("skills/my-skill/assets").is_dir()
|
||||
|
||||
def test_create_outside_project(self, skill_command, tmp_path):
|
||||
with in_temp_dir():
|
||||
skill_command.create("standalone-skill", in_project=False)
|
||||
assert Path("standalone-skill/SKILL.md").exists()
|
||||
|
||||
def test_create_adds_name_to_skill_md(self, skill_command):
|
||||
with in_temp_dir():
|
||||
skill_command.create("hello-world", in_project=False)
|
||||
content = Path("hello-world/SKILL.md").read_text()
|
||||
assert "name: hello-world" in content
|
||||
assert "version: 0.1.0" in content
|
||||
|
||||
def test_create_fails_if_dir_exists(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("existing-skill").mkdir()
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.create("existing-skill", in_project=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillInstall:
|
||||
def _zip_skill(self, name: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("SKILL.md", f"---\nname: {name}\ndescription: Test.\n---\nInstructions.")
|
||||
return buf.getvalue()
|
||||
|
||||
def test_install_invalid_ref_no_at(self, skill_command):
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("acme/my-skill")
|
||||
|
||||
def test_install_invalid_ref_no_slash(self, skill_command):
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("@acmeskill")
|
||||
|
||||
def test_install_404(self, skill_command):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.install("@acme/ghost")
|
||||
|
||||
def test_install_in_project(self, skill_command):
|
||||
import base64
|
||||
archive = self._zip_skill("my-skill")
|
||||
encoded = "data:application/zip;base64," + base64.b64encode(archive).decode()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"file": encoded, "version": "1.0.0"}
|
||||
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
||||
|
||||
with in_temp_dir():
|
||||
Path("pyproject.toml").write_text("[tool]\n")
|
||||
skill_command.install("@acme/my-skill")
|
||||
assert Path("skills/my-skill/SKILL.md").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# publish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillPublish:
|
||||
def test_publish_no_skill_md(self, skill_command):
|
||||
with in_temp_dir():
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_missing_version(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: Test.\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_missing_name(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\ndescription: Test.\nversion: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
|
||||
def test_publish_no_org(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 1.0.0\ndescription: Test.\n---\nInstructions."
|
||||
)
|
||||
with patch.object(skill_command, "plus_api_client") as mock_client:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = True
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client.publish_skill.return_value = mock_resp
|
||||
# No org set → should SystemExit (no org_name in settings)
|
||||
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
||||
mock_settings_cls.return_value.org_name = None
|
||||
mock_settings_cls.return_value.enterprise_base_url = None
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org=None)
|
||||
|
||||
def test_publish_calls_api(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 1.0.0\ndescription: A test skill.\n---\nInstructions."
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = True
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
|
||||
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
||||
mock_settings_cls.return_value.org_name = "acme"
|
||||
mock_settings_cls.return_value.enterprise_base_url = None
|
||||
|
||||
skill_command.publish(is_public=False, org="acme")
|
||||
|
||||
skill_command.plus_api_client.publish_skill.assert_called_once()
|
||||
call_kwargs = skill_command.plus_api_client.publish_skill.call_args
|
||||
assert call_kwargs.kwargs["name"] == "my-skill"
|
||||
assert call_kwargs.kwargs["version"] == "1.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_cached
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillListCached:
|
||||
def test_list_cached_empty(self, skill_command, capsys):
|
||||
with in_temp_dir():
|
||||
skill_command.list_cached()
|
||||
# Should not raise
|
||||
|
||||
def test_list_cached_shows_project_skills(self, skill_command, capsys):
|
||||
with in_temp_dir():
|
||||
skill_dir = Path("skills/my-skill")
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: my-skill\nversion: 0.5.0\ndescription: A skill.\n---\nBody."
|
||||
)
|
||||
skill_command.list_cached()
|
||||
# Should complete without error
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
@@ -140,6 +140,7 @@ class PlusAPI:
|
||||
"""Client for working with the CrewAI+ API."""
|
||||
|
||||
TOOLS_RESOURCE: Final = "/crewai_plus/api/v1/tools"
|
||||
SKILLS_RESOURCE: Final = "/crewai_plus/api/v1/skills"
|
||||
ORGANIZATIONS_RESOURCE: Final = "/crewai_plus/api/v1/me/organizations"
|
||||
CREWS_RESOURCE: Final = "/crewai_plus/api/v1/crews"
|
||||
AGENTS_RESOURCE: Final = "/crewai_plus/api/v1/agents"
|
||||
@@ -228,6 +229,47 @@ class PlusAPI:
|
||||
}
|
||||
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
|
||||
|
||||
def get_skill(
|
||||
self, org: str, name: str, version: str | None = None
|
||||
) -> httpx.Response:
|
||||
params: dict[str, str] = {}
|
||||
if version is not None:
|
||||
params["version"] = version
|
||||
return self._make_request(
|
||||
"GET",
|
||||
f"{self.SKILLS_RESOURCE}/{org}/{name}",
|
||||
params=params or None,
|
||||
)
|
||||
|
||||
def publish_skill(
|
||||
self,
|
||||
org: str,
|
||||
name: str,
|
||||
version: str,
|
||||
is_public: bool,
|
||||
description: str | None,
|
||||
encoded_file: str,
|
||||
) -> httpx.Response:
|
||||
payload = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"public": is_public,
|
||||
"description": description,
|
||||
"file": encoded_file,
|
||||
}
|
||||
return self._make_request("POST", self.SKILLS_RESOURCE, json=payload)
|
||||
|
||||
def list_skills(self, org: str | None = None) -> httpx.Response:
|
||||
params: dict[str, str] = {}
|
||||
if org is not None:
|
||||
params["org"] = org
|
||||
return self._make_request(
|
||||
"GET",
|
||||
self.SKILLS_RESOURCE,
|
||||
params=params or None,
|
||||
)
|
||||
|
||||
def deploy_by_name(self, project_name: str) -> httpx.Response:
|
||||
return self._make_request(
|
||||
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"pytube~=15.0.0",
|
||||
"requests>=2.33.0,<3",
|
||||
"crewai==1.14.5",
|
||||
"crewai==1.14.6a1",
|
||||
"tiktoken>=0.8.0,<0.13",
|
||||
"beautifulsoup4~=4.13.4",
|
||||
"python-docx~=1.2.0",
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
@@ -103,7 +103,7 @@ class MongoDBVectorSearchTool(BaseTool):
|
||||
),
|
||||
]
|
||||
)
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["mongdb"])
|
||||
package_dependencies: list[str] = Field(default_factory=lambda: ["pymongo"])
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@@ -14633,7 +14633,7 @@
|
||||
},
|
||||
"name": "MongoDBVectorSearchTool",
|
||||
"package_dependencies": [
|
||||
"mongdb"
|
||||
"pymongo"
|
||||
],
|
||||
"run_params_schema": {
|
||||
"description": "Input for MongoDBTool.",
|
||||
|
||||
@@ -8,8 +8,8 @@ authors = [
|
||||
]
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"crewai-core==1.14.5",
|
||||
"crewai-cli==1.14.5",
|
||||
"crewai-core==1.14.6a1",
|
||||
"crewai-cli==1.14.6a1",
|
||||
# Core Dependencies
|
||||
"pydantic>=2.11.9,<2.13",
|
||||
"openai>=2.30.0,<3",
|
||||
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
|
||||
|
||||
[project.optional-dependencies]
|
||||
tools = [
|
||||
"crewai-tools==1.14.5",
|
||||
"crewai-tools==1.14.6a1",
|
||||
]
|
||||
embeddings = [
|
||||
"tiktoken>=0.8.0,<0.13"
|
||||
|
||||
@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
|
||||
|
||||
_suppress_pydantic_deprecation_warnings()
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"Memory": ("crewai.memory.unified_memory", "Memory"),
|
||||
|
||||
@@ -434,7 +434,7 @@ class Agent(BaseAgent):
|
||||
from crewai.crew import Crew
|
||||
|
||||
if resolved_crew_skills is None:
|
||||
crew_skills: list[Path | SkillModel] | None = (
|
||||
crew_skills: list[Path | SkillModel | str] | None = (
|
||||
self.crew.skills
|
||||
if isinstance(self.crew, Crew) and isinstance(self.crew.skills, list)
|
||||
else None
|
||||
@@ -446,7 +446,7 @@ class Agent(BaseAgent):
|
||||
return
|
||||
|
||||
needs_work = self.skills and any(
|
||||
isinstance(s, Path)
|
||||
isinstance(s, (Path, str))
|
||||
or (isinstance(s, SkillModel) and s.disclosure_level < INSTRUCTIONS)
|
||||
for s in self.skills
|
||||
)
|
||||
@@ -454,14 +454,28 @@ class Agent(BaseAgent):
|
||||
return
|
||||
|
||||
seen: set[str] = set()
|
||||
resolved: list[Path | SkillModel] = []
|
||||
items: list[Path | SkillModel] = list(self.skills) if self.skills else []
|
||||
resolved: list[Path | SkillModel | str] = []
|
||||
items: list[Path | SkillModel | str] = list(self.skills) if self.skills else []
|
||||
|
||||
if crew_skills:
|
||||
items.extend(crew_skills)
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, Path):
|
||||
if isinstance(item, str):
|
||||
from crewai.skills.registry import (
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
resolve_registry_ref,
|
||||
)
|
||||
|
||||
if is_registry_ref(item):
|
||||
skill = resolve_registry_ref(item, source=self)
|
||||
org, _ = parse_registry_ref(item)
|
||||
dedup_key = f"{org}/{skill.name}"
|
||||
if dedup_key not in seen:
|
||||
seen.add(dedup_key)
|
||||
resolved.append(skill)
|
||||
elif isinstance(item, Path):
|
||||
discovered = discover_skills(item, source=self)
|
||||
for skill in discovered:
|
||||
if skill.name not in seen:
|
||||
|
||||
@@ -31,13 +31,13 @@ from crewai.agents.tools_handler import ToolsHandler
|
||||
from crewai.events.base_events import set_emission_counter
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.event_context import restore_event_scope, set_last_event_id
|
||||
from crewai.knowledge.knowledge import Knowledge
|
||||
from crewai.knowledge.knowledge import Knowledge, _resolve_knowledge_sources
|
||||
from crewai.knowledge.knowledge_config import KnowledgeConfig
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.mcp.config import MCPServerConfig
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.security.security_config import SecurityConfig
|
||||
@@ -127,6 +127,13 @@ def _validate_executor_ref(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_executor_ref(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
result: dict[str, Any] = value.model_dump(mode="json")
|
||||
return result
|
||||
|
||||
|
||||
def _serialize_llm_ref(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -251,14 +258,13 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
max_iter: int = Field(
|
||||
default=25, description="Maximum iterations for an agent to execute a task"
|
||||
)
|
||||
agent_executor: SerializeAsAny[BaseAgentExecutor] | None = Field(
|
||||
default=None, description="An instance of the CrewAgentExecutor class."
|
||||
)
|
||||
|
||||
@field_validator("agent_executor", mode="before")
|
||||
@classmethod
|
||||
def _validate_agent_executor(cls, v: Any) -> Any:
|
||||
return _validate_executor_ref(v)
|
||||
agent_executor: Annotated[
|
||||
SerializeAsAny[BaseAgentExecutor] | None,
|
||||
BeforeValidator(_validate_executor_ref),
|
||||
PlainSerializer(
|
||||
_serialize_executor_ref, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None, description="An instance of the CrewAgentExecutor class.")
|
||||
|
||||
llm: Annotated[
|
||||
str | BaseLLM | None,
|
||||
@@ -288,7 +294,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
knowledge: Knowledge | None = Field(
|
||||
default=None, description="Knowledge for the agent."
|
||||
)
|
||||
knowledge_sources: list[BaseKnowledgeSource] | None = Field(
|
||||
knowledge_sources: Annotated[
|
||||
list[BaseKnowledgeSource] | None,
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(
|
||||
default=None,
|
||||
description="Knowledge sources for the agent.",
|
||||
)
|
||||
@@ -326,7 +335,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
default=None,
|
||||
description="List of MCP server references. Supports 'https://server.com/path' for external servers and bare slugs like 'notion' for connected MCP integrations. Use '#tool_name' suffix for specific tools.",
|
||||
)
|
||||
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
|
||||
memory: Annotated[
|
||||
bool
|
||||
| Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Enable agent memory. Pass True for default Memory(), "
|
||||
@@ -334,9 +350,9 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
"If not set, falls back to crew memory."
|
||||
),
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
skills: list[Path | Skill | str] | None = Field(
|
||||
default=None,
|
||||
description="Agent Skills. Accepts paths for discovery or pre-loaded Skill objects.",
|
||||
description="Agent Skills. Accepts paths for discovery, pre-loaded Skill objects, or '@org/name' registry refs.",
|
||||
min_length=1,
|
||||
)
|
||||
execution_context: ExecutionContext | None = Field(default=None)
|
||||
@@ -397,8 +413,21 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
self.agent_executor._resuming = True
|
||||
if self.checkpoint_kickoff_event_id is not None:
|
||||
self._kickoff_event_id = self.checkpoint_kickoff_event_id
|
||||
self._rebind_memory_view()
|
||||
self._restore_event_scope(state)
|
||||
|
||||
def _rebind_memory_view(self) -> None:
|
||||
"""Reattach a fresh ``Memory`` to a restored ``MemoryScope``/``MemorySlice``.
|
||||
|
||||
Checkpoint JSON omits the live ``Memory`` dependency, so scoped
|
||||
memory views raise ``RuntimeError`` on first use after restore.
|
||||
"""
|
||||
if (
|
||||
isinstance(self.memory, MemoryScope | MemorySlice)
|
||||
and self.memory._memory is None
|
||||
):
|
||||
self.memory.bind(Memory())
|
||||
|
||||
def _restore_event_scope(self, state: RuntimeState) -> None:
|
||||
"""Rebuild the event scope stack from the checkpoint's event record.
|
||||
|
||||
@@ -429,6 +458,20 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
|
||||
def process_model_config(cls, values: Any) -> dict[str, Any]:
|
||||
return process_config(values, cls)
|
||||
|
||||
@field_validator("skills", mode="before")
|
||||
@classmethod
|
||||
def coerce_skill_strings(cls, skills: Any) -> Any:
|
||||
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
|
||||
if not isinstance(skills, list):
|
||||
return skills
|
||||
result = []
|
||||
for item in skills:
|
||||
if isinstance(item, str) and not item.startswith("@"):
|
||||
result.append(Path(item))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator("tools")
|
||||
@classmethod
|
||||
def validate_tools(cls, tools: list[Any]) -> list[BaseTool]:
|
||||
|
||||
@@ -93,11 +93,11 @@ from crewai.events.types.crew_events import (
|
||||
CrewTrainStartedEvent,
|
||||
)
|
||||
from crewai.flow.flow_trackable import FlowTrackable
|
||||
from crewai.knowledge.knowledge import Knowledge
|
||||
from crewai.knowledge.knowledge import Knowledge, _resolve_knowledge_sources
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.process import Process
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
@@ -223,7 +223,14 @@ class Crew(FlowTrackable, BaseModel):
|
||||
] = Field(default_factory=list)
|
||||
process: Process = Field(default=Process.sequential)
|
||||
verbose: bool = Field(default=False)
|
||||
memory: bool | Memory | MemoryScope | MemorySlice | None = Field(
|
||||
memory: Annotated[
|
||||
bool
|
||||
| Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Enable crew memory. Pass True for default Memory(), "
|
||||
@@ -322,7 +329,10 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default_factory=list,
|
||||
description="list of execution logs for tasks",
|
||||
)
|
||||
knowledge_sources: list[BaseKnowledgeSource] | None = Field(
|
||||
knowledge_sources: Annotated[
|
||||
list[BaseKnowledgeSource] | None,
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Knowledge sources for the crew. Add knowledge sources to the "
|
||||
@@ -341,9 +351,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
default=None,
|
||||
description="Knowledge for the crew.",
|
||||
)
|
||||
skills: list[Path | Skill] | None = Field(
|
||||
skills: list[Path | Skill | str] | None = Field(
|
||||
default=None,
|
||||
description="Skill search paths or pre-loaded Skill objects applied to all agents in the crew.",
|
||||
description="Skill search paths, pre-loaded Skill objects, or '@org/name' registry refs applied to all agents in the crew.",
|
||||
)
|
||||
|
||||
security_config: SecurityConfig = Field(
|
||||
@@ -477,8 +487,42 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if self.checkpoint_train is not None:
|
||||
self._train = self.checkpoint_train
|
||||
|
||||
self._rebind_memory_views()
|
||||
self._restore_event_scope()
|
||||
|
||||
def _rebind_memory_views(self) -> None:
|
||||
"""Reattach a live ``Memory`` to restored ``MemoryScope``/``MemorySlice`` views.
|
||||
|
||||
Checkpoint JSON omits the live ``Memory`` dependency on scope/slice
|
||||
views, so after restore they raise ``RuntimeError`` on first use.
|
||||
Prefer the crew's restored ``Memory`` (from ``create_crew_memory``
|
||||
or a ``Crew.memory=Memory(...)`` instance) so all views share one
|
||||
backing store; fall back to a fresh ``Memory()`` only if nothing is
|
||||
available.
|
||||
"""
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
backing: Memory | None = None
|
||||
if isinstance(self._memory, Memory):
|
||||
backing = self._memory
|
||||
elif isinstance(self.memory, Memory):
|
||||
backing = self.memory
|
||||
|
||||
def _ensure(view: Any) -> None:
|
||||
nonlocal backing
|
||||
if not isinstance(view, MemoryScope | MemorySlice):
|
||||
return
|
||||
if view._memory is not None:
|
||||
return
|
||||
if backing is None:
|
||||
backing = Memory()
|
||||
view.bind(backing)
|
||||
|
||||
_ensure(self.memory)
|
||||
for agent in self.agents:
|
||||
_ensure(agent.memory)
|
||||
|
||||
def _restore_event_scope(self) -> None:
|
||||
"""Rebuild the event scope stack from the checkpoint's event record."""
|
||||
from crewai.events.base_events import set_emission_counter
|
||||
@@ -526,6 +570,20 @@ class Crew(FlowTrackable, BaseModel):
|
||||
if max_seq > 0:
|
||||
set_emission_counter(max_seq)
|
||||
|
||||
@field_validator("skills", mode="before")
|
||||
@classmethod
|
||||
def coerce_skill_strings(cls, skills: Any) -> Any:
|
||||
"""Coerce plain path strings to Path objects; keep @-prefixed refs as str."""
|
||||
if not isinstance(skills, list):
|
||||
return skills
|
||||
result = []
|
||||
for item in skills:
|
||||
if isinstance(item, str) and not item.startswith("@"):
|
||||
result.append(Path(item))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
@classmethod
|
||||
def _deny_user_set_id(cls, v: UUID4 | None, info: Any) -> UUID4 | None:
|
||||
|
||||
@@ -60,3 +60,20 @@ class SkillLoadFailedEvent(SkillEvent):
|
||||
|
||||
type: Literal["skill_load_failed"] = "skill_load_failed"
|
||||
error: str
|
||||
|
||||
|
||||
class SkillDownloadStartedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download begins."""
|
||||
|
||||
type: Literal["skill_download_started"] = "skill_download_started"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class SkillDownloadCompletedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download completes."""
|
||||
|
||||
type: Literal["skill_download_completed"] = "skill_download_completed"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
cache_path: Path | None = None
|
||||
|
||||
@@ -113,7 +113,7 @@ from crewai.flow.utils import (
|
||||
is_flow_method_name,
|
||||
is_simple_flow_condition,
|
||||
)
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice
|
||||
from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind
|
||||
from crewai.memory.unified_memory import Memory
|
||||
from crewai.state.checkpoint_config import (
|
||||
CheckpointConfig,
|
||||
@@ -159,6 +159,39 @@ def _resolve_persistence(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_persistence(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, FlowPersistence):
|
||||
return value.model_dump(mode="json")
|
||||
raise TypeError(
|
||||
f"Cannot serialize Flow.persistence of type {type(value).__name__}: "
|
||||
"expected FlowPersistence or None."
|
||||
)
|
||||
|
||||
|
||||
def _validate_input_provider(value: Any) -> Any:
|
||||
if value is None or isinstance(value, InputProvider):
|
||||
return value
|
||||
from crewai.types.callback import _dotted_path_to_instance
|
||||
|
||||
resolved = _dotted_path_to_instance(value)
|
||||
if resolved is None or isinstance(resolved, InputProvider):
|
||||
return resolved
|
||||
raise ValueError(
|
||||
f"Resolved input_provider {resolved!r} does not implement the "
|
||||
"InputProvider protocol (missing request_input)."
|
||||
)
|
||||
|
||||
|
||||
def _serialize_input_provider(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
from crewai.types.callback import _instance_to_dotted_path
|
||||
|
||||
return _instance_to_dotted_path(value)
|
||||
|
||||
|
||||
_INITIAL_STATE_CLASS_MARKER = "__crewai_pydantic_class_schema__"
|
||||
|
||||
|
||||
@@ -949,15 +982,30 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
name: str | None = Field(default=None)
|
||||
tracing: bool | None = Field(default=None)
|
||||
stream: bool = Field(default=False)
|
||||
memory: Memory | MemoryScope | MemorySlice | None = Field(default=None)
|
||||
input_provider: InputProvider | None = Field(default=None)
|
||||
memory: Annotated[
|
||||
Annotated[
|
||||
Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind")
|
||||
]
|
||||
| None,
|
||||
BeforeValidator(_ensure_memory_kind),
|
||||
] = Field(default=None)
|
||||
input_provider: Annotated[
|
||||
InputProvider | None,
|
||||
BeforeValidator(_validate_input_provider),
|
||||
PlainSerializer(
|
||||
_serialize_input_provider, return_type=str | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None)
|
||||
suppress_flow_events: bool = Field(default=False)
|
||||
human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list)
|
||||
last_human_feedback: HumanFeedbackResult | None = Field(default=None)
|
||||
|
||||
persistence: Annotated[
|
||||
SerializeAsAny[FlowPersistence] | Any,
|
||||
SerializeAsAny[FlowPersistence] | None,
|
||||
BeforeValidator(lambda v, _: _resolve_persistence(v)),
|
||||
PlainSerializer(
|
||||
_serialize_persistence, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = Field(default=None)
|
||||
max_method_calls: int = Field(default=100)
|
||||
|
||||
@@ -1050,6 +1098,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
}
|
||||
if self.checkpoint_state is not None:
|
||||
self._restore_state(self.checkpoint_state)
|
||||
if (
|
||||
isinstance(self.memory, MemoryScope | MemorySlice)
|
||||
and self.memory._memory is None
|
||||
):
|
||||
self.memory.bind(Memory())
|
||||
restore_event_scope(())
|
||||
reset_last_event_id()
|
||||
|
||||
|
||||
@@ -1,16 +1,89 @@
|
||||
import os
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer
|
||||
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.knowledge.source.crew_docling_source import CrewDoclingSource
|
||||
from crewai.knowledge.source.csv_knowledge_source import CSVKnowledgeSource
|
||||
from crewai.knowledge.source.excel_knowledge_source import ExcelKnowledgeSource
|
||||
from crewai.knowledge.source.json_knowledge_source import JSONKnowledgeSource
|
||||
from crewai.knowledge.source.pdf_knowledge_source import PDFKnowledgeSource
|
||||
from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSource
|
||||
from crewai.knowledge.source.text_file_knowledge_source import (
|
||||
TextFileKnowledgeSource,
|
||||
)
|
||||
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
|
||||
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
|
||||
from crewai.rag.embeddings.types import EmbedderConfig
|
||||
from crewai.rag.types import SearchResult
|
||||
|
||||
|
||||
_KNOWN_SOURCES: dict[str, type[BaseKnowledgeSource]] = {
|
||||
"string": StringKnowledgeSource,
|
||||
"docling": CrewDoclingSource,
|
||||
"csv": CSVKnowledgeSource,
|
||||
"excel": ExcelKnowledgeSource,
|
||||
"json": JSONKnowledgeSource,
|
||||
"pdf": PDFKnowledgeSource,
|
||||
"text_file": TextFileKnowledgeSource,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_knowledge_sources(value: Any) -> Any:
|
||||
"""Coerce list of dicts into typed BaseKnowledgeSource subclasses via source_type.
|
||||
|
||||
Pass-through for anything else (existing instances, mocks).
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
return value
|
||||
resolved: list[Any] = []
|
||||
for idx, item in enumerate(value):
|
||||
if isinstance(item, dict):
|
||||
tag = item.get("source_type")
|
||||
if not isinstance(tag, str):
|
||||
resolved.append(item)
|
||||
continue
|
||||
cls = _KNOWN_SOURCES.get(tag)
|
||||
if cls is None:
|
||||
raise ValueError(
|
||||
f"Unknown source_type={tag!r} at index {idx}: "
|
||||
f"expected one of {sorted(_KNOWN_SOURCES)}"
|
||||
)
|
||||
try:
|
||||
resolved.append(cls.model_validate(item))
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"Failed to validate knowledge source at index {idx} "
|
||||
f"with source_type={tag!r}: {exc}"
|
||||
) from exc
|
||||
else:
|
||||
resolved.append(item)
|
||||
return resolved
|
||||
|
||||
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false" # removes logging from fastembed
|
||||
|
||||
|
||||
def _serialize_embedder_spec(value: Any) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, BaseEmbeddingsProvider):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, type) and issubclass(value, BaseEmbeddingsProvider):
|
||||
raise TypeError(
|
||||
f"Cannot checkpoint embedder class {value.__module__}.{value.__qualname__}: "
|
||||
"build_embedder requires an instance or ProviderSpec dict, not a class. "
|
||||
"Instantiate the provider before assigning it to Knowledge.embedder."
|
||||
)
|
||||
raise TypeError(
|
||||
f"Cannot serialize embedder of type {type(value).__name__}: "
|
||||
"expected ProviderSpec dict or BaseEmbeddingsProvider instance."
|
||||
)
|
||||
|
||||
|
||||
class Knowledge(BaseModel):
|
||||
"""
|
||||
Knowledge is a collection of sources and setup for the vector store to save and query relevant context.
|
||||
@@ -20,10 +93,18 @@ class Knowledge(BaseModel):
|
||||
embedder: EmbedderConfig | None = None
|
||||
"""
|
||||
|
||||
sources: list[BaseKnowledgeSource] = Field(default_factory=list)
|
||||
sources: Annotated[
|
||||
list[BaseKnowledgeSource],
|
||||
BeforeValidator(_resolve_knowledge_sources),
|
||||
] = Field(default_factory=list)
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
storage: KnowledgeStorage | None = Field(default=None)
|
||||
embedder: EmbedderConfig | None = None
|
||||
embedder: Annotated[
|
||||
EmbedderConfig | None,
|
||||
PlainSerializer(
|
||||
_serialize_embedder_spec, return_type=dict | None, when_used="json"
|
||||
),
|
||||
] = None
|
||||
collection_name: str | None = None
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -13,7 +13,9 @@ class BaseKnowledgeSource(BaseModel, ABC):
|
||||
chunk_size: int = 4000
|
||||
chunk_overlap: int = 200
|
||||
chunks: list[str] = Field(default_factory=list)
|
||||
chunk_embeddings: list[np.ndarray[Any, np.dtype[Any]]] = Field(default_factory=list)
|
||||
chunk_embeddings: list[np.ndarray[Any, np.dtype[Any]]] = Field(
|
||||
default_factory=list, exclude=True
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
storage: KnowledgeStorage | None = Field(default=None)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class CrewDoclingSource(BaseKnowledgeSource):
|
||||
|
||||
_logger: Logger = Logger(verbose=True)
|
||||
|
||||
source_type: Literal["docling"] = "docling"
|
||||
file_path: list[Path | str] | None = Field(default=None)
|
||||
file_paths: list[Path | str] = Field(default_factory=list)
|
||||
chunks: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -7,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class CSVKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries CSV file content using embeddings."""
|
||||
|
||||
source_type: Literal["csv"] = "csv"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess CSV file content."""
|
||||
content_dict = {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
@@ -16,6 +16,7 @@ class ExcelKnowledgeSource(BaseKnowledgeSource):
|
||||
|
||||
_logger: Logger = Logger(verbose=True)
|
||||
|
||||
source_type: Literal["excel"] = "excel"
|
||||
file_path: Path | list[Path] | str | list[str] | None = Field(
|
||||
default=None,
|
||||
description="[Deprecated] The path to the file. Use file_paths instead.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -8,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class JSONKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries JSON file content using embeddings."""
|
||||
|
||||
source_type: Literal["json"] = "json"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess JSON file content."""
|
||||
content: dict[Path, str] = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -7,6 +8,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class PDFKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries PDF file content using embeddings."""
|
||||
|
||||
source_type: Literal["pdf"] = "pdf"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess PDF file content."""
|
||||
pdfplumber = self._import_pdfplumber()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@@ -8,6 +8,7 @@ from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
class StringKnowledgeSource(BaseKnowledgeSource):
|
||||
"""A knowledge source that stores and queries plain text content using embeddings."""
|
||||
|
||||
source_type: Literal["string"] = "string"
|
||||
content: str = Field(...)
|
||||
collection_name: str | None = Field(default=None)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledgeSource
|
||||
|
||||
@@ -6,6 +7,8 @@ from crewai.knowledge.source.base_file_knowledge_source import BaseFileKnowledge
|
||||
class TextFileKnowledgeSource(BaseFileKnowledgeSource):
|
||||
"""A knowledge source that stores and queries text file content using embeddings."""
|
||||
|
||||
source_type: Literal["text_file"] = "text_file"
|
||||
|
||||
def load_content(self) -> dict[Path, str]:
|
||||
"""Load and preprocess text file content."""
|
||||
content = {}
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.memory.types import (
|
||||
_RECALL_OVERSAMPLE_FACTOR,
|
||||
@@ -16,15 +17,35 @@ from crewai.memory.types import (
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
|
||||
def _ensure_memory_kind(value: Any) -> Any:
|
||||
"""Backfill ``memory_kind`` on legacy dicts that predate the discriminator.
|
||||
|
||||
Lets pre-1.14.6 configs/checkpoints flow into the discriminated
|
||||
``Memory | MemoryScope | MemorySlice`` union without crashing. Inference:
|
||||
``scopes`` key → ``slice``; ``root_path`` → ``scope``; else ``memory``.
|
||||
Pass-through for non-dict values (instances, ``bool``, ``None``).
|
||||
"""
|
||||
if isinstance(value, dict) and "memory_kind" not in value:
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
value["memory_kind"] = "scope"
|
||||
else:
|
||||
value["memory_kind"] = "memory"
|
||||
return value
|
||||
|
||||
|
||||
class MemoryScope(BaseModel):
|
||||
"""View of Memory restricted to a root path. All operations are scoped under that path."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["scope"] = "scope"
|
||||
|
||||
root_path: str = Field(default="/")
|
||||
|
||||
_memory: Memory = PrivateAttr()
|
||||
_root: str = PrivateAttr()
|
||||
_memory: Memory | None = PrivateAttr(default=None)
|
||||
_root: str = PrivateAttr(default="")
|
||||
|
||||
@model_validator(mode="wrap")
|
||||
@classmethod
|
||||
@@ -34,21 +55,38 @@ class MemoryScope(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemoryScope, got {type(data).__name__}")
|
||||
if "memory" not in data:
|
||||
raise ValueError("MemoryScope requires a 'memory' key")
|
||||
memory = data.pop("memory")
|
||||
memory = data.pop("memory", None)
|
||||
instance: MemoryScope = handler(data)
|
||||
instance._memory = memory
|
||||
if memory is not None:
|
||||
instance._memory = memory
|
||||
root = instance.root_path.rstrip("/") or ""
|
||||
if root and not root.startswith("/"):
|
||||
root = "/" + root
|
||||
instance._root = root
|
||||
return instance
|
||||
|
||||
def bind(self, memory: Memory) -> Self:
|
||||
"""Rebind the runtime ``Memory`` dependency after restore.
|
||||
|
||||
Required after deserializing from a checkpoint, since the live
|
||||
``Memory`` cannot be serialized.
|
||||
"""
|
||||
self._memory = memory
|
||||
return self
|
||||
|
||||
def _require_memory(self) -> Memory:
|
||||
"""Return the bound ``Memory`` or raise a clear error if missing."""
|
||||
if self._memory is None:
|
||||
raise RuntimeError(
|
||||
"MemoryScope is not bound to a Memory; call .bind(memory) "
|
||||
"after restore."
|
||||
)
|
||||
return self._memory
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
"""Whether the underlying memory is read-only."""
|
||||
return self._memory.read_only
|
||||
return self._require_memory().read_only
|
||||
|
||||
def _scope_path(self, scope: str | None) -> str:
|
||||
if not scope or scope == "/":
|
||||
@@ -73,7 +111,7 @@ class MemoryScope(BaseModel):
|
||||
) -> MemoryRecord | None:
|
||||
"""Remember content; scope is relative to this scope's root."""
|
||||
path = self._scope_path(scope)
|
||||
return self._memory.remember(
|
||||
return self._require_memory().remember(
|
||||
content,
|
||||
scope=path,
|
||||
categories=categories,
|
||||
@@ -96,7 +134,7 @@ class MemoryScope(BaseModel):
|
||||
) -> list[MemoryRecord]:
|
||||
"""Remember multiple items; scope is relative to this scope's root."""
|
||||
path = self._scope_path(scope)
|
||||
return self._memory.remember_many(
|
||||
return self._require_memory().remember_many(
|
||||
contents,
|
||||
scope=path,
|
||||
categories=categories,
|
||||
@@ -119,7 +157,7 @@ class MemoryScope(BaseModel):
|
||||
) -> list[MemoryMatch]:
|
||||
"""Recall within this scope (root path and below)."""
|
||||
search_scope = self._scope_path(scope) if scope else (self._root or "/")
|
||||
return self._memory.recall(
|
||||
return self._require_memory().recall(
|
||||
query,
|
||||
scope=search_scope,
|
||||
categories=categories,
|
||||
@@ -131,7 +169,7 @@ class MemoryScope(BaseModel):
|
||||
|
||||
def extract_memories(self, content: str) -> list[str]:
|
||||
"""Extract discrete memories from content; delegates to underlying Memory."""
|
||||
return self._memory.extract_memories(content)
|
||||
return self._require_memory().extract_memories(content)
|
||||
|
||||
def forget(
|
||||
self,
|
||||
@@ -143,7 +181,7 @@ class MemoryScope(BaseModel):
|
||||
) -> int:
|
||||
"""Forget within this scope."""
|
||||
prefix = self._scope_path(scope) if scope else (self._root or "/")
|
||||
return self._memory.forget(
|
||||
return self._require_memory().forget(
|
||||
scope=prefix,
|
||||
categories=categories,
|
||||
older_than=older_than,
|
||||
@@ -154,27 +192,27 @@ class MemoryScope(BaseModel):
|
||||
def list_scopes(self, path: str = "/") -> list[str]:
|
||||
"""List child scopes under path (relative to this scope's root)."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.list_scopes(full)
|
||||
return self._require_memory().list_scopes(full)
|
||||
|
||||
def info(self, path: str = "/") -> ScopeInfo:
|
||||
"""Info for path under this scope."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.info(full)
|
||||
return self._require_memory().info(full)
|
||||
|
||||
def tree(self, path: str = "/", max_depth: int = 3) -> str:
|
||||
"""Tree under path within this scope."""
|
||||
full = self._scope_path(path)
|
||||
return self._memory.tree(full, max_depth=max_depth)
|
||||
return self._require_memory().tree(full, max_depth=max_depth)
|
||||
|
||||
def list_categories(self, path: str | None = None) -> dict[str, int]:
|
||||
"""Categories in this scope; path None means this scope root."""
|
||||
full = self._scope_path(path) if path else (self._root or "/")
|
||||
return self._memory.list_categories(full)
|
||||
return self._require_memory().list_categories(full)
|
||||
|
||||
def reset(self, scope: str | None = None) -> None:
|
||||
"""Reset within this scope."""
|
||||
prefix = self._scope_path(scope) if scope else (self._root or "/")
|
||||
self._memory.reset(scope=prefix)
|
||||
self._require_memory().reset(scope=prefix)
|
||||
|
||||
def subscope(self, path: str) -> MemoryScope:
|
||||
"""Return a narrower scope under this scope."""
|
||||
@@ -191,11 +229,13 @@ class MemorySlice(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["slice"] = "slice"
|
||||
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
categories: list[str] | None = Field(default=None)
|
||||
read_only: bool = Field(default=True)
|
||||
|
||||
_memory: Memory = PrivateAttr()
|
||||
_memory: Memory | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="wrap")
|
||||
@classmethod
|
||||
@@ -205,14 +245,27 @@ class MemorySlice(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemorySlice, got {type(data).__name__}")
|
||||
if "memory" not in data:
|
||||
raise ValueError("MemorySlice requires a 'memory' key")
|
||||
memory = data.pop("memory")
|
||||
memory = data.pop("memory", None)
|
||||
data["scopes"] = [s.rstrip("/") or "/" for s in data.get("scopes", [])]
|
||||
instance: MemorySlice = handler(data)
|
||||
instance._memory = memory
|
||||
if memory is not None:
|
||||
instance._memory = memory
|
||||
return instance
|
||||
|
||||
def bind(self, memory: Memory) -> Self:
|
||||
"""Rebind the runtime ``Memory`` dependency after restore."""
|
||||
self._memory = memory
|
||||
return self
|
||||
|
||||
def _require_memory(self) -> Memory:
|
||||
"""Return the bound ``Memory`` or raise a clear error if missing."""
|
||||
if self._memory is None:
|
||||
raise RuntimeError(
|
||||
"MemorySlice is not bound to a Memory; call .bind(memory) "
|
||||
"after restore."
|
||||
)
|
||||
return self._memory
|
||||
|
||||
def remember(
|
||||
self,
|
||||
content: str,
|
||||
@@ -226,7 +279,7 @@ class MemorySlice(BaseModel):
|
||||
"""Remember into an explicit scope. No-op when read_only=True."""
|
||||
if self.read_only:
|
||||
return None
|
||||
return self._memory.remember(
|
||||
return self._require_memory().remember(
|
||||
content,
|
||||
scope=scope,
|
||||
categories=categories,
|
||||
@@ -250,7 +303,7 @@ class MemorySlice(BaseModel):
|
||||
cats = categories or self.categories
|
||||
all_matches: list[MemoryMatch] = []
|
||||
for sc in self.scopes:
|
||||
matches = self._memory.recall(
|
||||
matches = self._require_memory().recall(
|
||||
query,
|
||||
scope=sc,
|
||||
categories=cats,
|
||||
@@ -272,14 +325,14 @@ class MemorySlice(BaseModel):
|
||||
|
||||
def extract_memories(self, content: str) -> list[str]:
|
||||
"""Extract discrete memories from content; delegates to underlying Memory."""
|
||||
return self._memory.extract_memories(content)
|
||||
return self._require_memory().extract_memories(content)
|
||||
|
||||
def list_scopes(self, path: str = "/") -> list[str]:
|
||||
"""List scopes across all slice roots."""
|
||||
out: list[str] = []
|
||||
for sc in self.scopes:
|
||||
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
|
||||
out.extend(self._memory.list_scopes(full))
|
||||
out.extend(self._require_memory().list_scopes(full))
|
||||
return sorted(set(out))
|
||||
|
||||
def info(self, path: str = "/") -> ScopeInfo:
|
||||
@@ -291,7 +344,7 @@ class MemorySlice(BaseModel):
|
||||
children: list[str] = []
|
||||
for sc in self.scopes:
|
||||
full = f"{sc.rstrip('/')}{path}" if sc != "/" else path
|
||||
inf = self._memory.info(full)
|
||||
inf = self._require_memory().info(full)
|
||||
total_records += inf.record_count
|
||||
all_categories.update(inf.categories)
|
||||
if inf.oldest_record:
|
||||
@@ -321,6 +374,6 @@ class MemorySlice(BaseModel):
|
||||
counts: dict[str, int] = {}
|
||||
for sc in self.scopes:
|
||||
full = (f"{sc.rstrip('/')}{path}" if sc != "/" else path) if path else sc
|
||||
for k, v in self._memory.list_categories(full).items():
|
||||
for k, v in self._require_memory().list_categories(full).items():
|
||||
counts[k] = counts.get(k, 0) + v
|
||||
return counts
|
||||
|
||||
@@ -63,6 +63,8 @@ class Memory(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
memory_kind: Literal["memory"] = "memory"
|
||||
|
||||
llm: Annotated[BaseLLM | str, PlainValidator(_passthrough)] = Field(
|
||||
default="gpt-4o-mini",
|
||||
description="LLM for analysis (model name or BaseLLM instance).",
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
Provides filesystem-based skill packaging with progressive disclosure.
|
||||
"""
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import Skill, SkillFrontmatter
|
||||
from crewai.skills.parser import SkillParseError
|
||||
from crewai.skills.registry import is_registry_ref, resolve_registry_ref
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillCacheManager",
|
||||
"SkillFrontmatter",
|
||||
"SkillParseError",
|
||||
"activate_skill",
|
||||
"discover_skills",
|
||||
"is_registry_ref",
|
||||
"resolve_registry_ref",
|
||||
]
|
||||
|
||||
148
lib/crewai/src/crewai/skills/cache.py
Normal file
148
lib/crewai/src/crewai/skills/cache.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Cache manager for registry-downloaded skills.
|
||||
|
||||
Manages ~/.crewai/skills/{org}/{name}/ as the global skill cache.
|
||||
One version is stored per skill (last install wins).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
from typing import TypedDict
|
||||
import zipfile
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CACHE_ROOT = Path.home() / ".crewai" / "skills"
|
||||
_META_FILENAME = ".crewai_meta.json"
|
||||
|
||||
|
||||
class SkillMetadata(TypedDict):
|
||||
org: str
|
||||
name: str
|
||||
version: str | None
|
||||
installed_at: str
|
||||
|
||||
|
||||
class SkillCacheManager:
|
||||
"""Manages the global skill cache at ~/.crewai/skills/."""
|
||||
|
||||
def __init__(self, cache_root: Path | None = None) -> None:
|
||||
self._root = cache_root or _CACHE_ROOT
|
||||
|
||||
def _skill_dir(self, org: str, name: str) -> Path:
|
||||
return self._root / org / name
|
||||
|
||||
def get_cached_path(self, org: str, name: str) -> Path | None:
|
||||
"""Return the cached skill directory path if it exists, else None."""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
meta_file = skill_dir / _META_FILENAME
|
||||
if skill_dir.is_dir() and meta_file.exists():
|
||||
return skill_dir
|
||||
return None
|
||||
|
||||
def store(
|
||||
self, org: str, name: str, version: str | None, archive_bytes: bytes
|
||||
) -> Path:
|
||||
"""Unpack an archive into the cache and write metadata.
|
||||
|
||||
Uses tarfile with filter='data' for path-traversal protection.
|
||||
|
||||
Args:
|
||||
org: Organisation slug.
|
||||
name: Skill name.
|
||||
version: Semantic version string, or None if unknown.
|
||||
archive_bytes: Raw bytes of a .tar.gz archive.
|
||||
|
||||
Returns:
|
||||
Path to the stored skill directory.
|
||||
"""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
# Wipe any previous version
|
||||
if skill_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(skill_dir)
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import io
|
||||
|
||||
# Try tar.gz first, fall back to zip
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf:
|
||||
try:
|
||||
tf.extractall(skill_dir, filter="data")
|
||||
except TypeError:
|
||||
_safe_extractall(tf, skill_dir)
|
||||
except tarfile.TarError:
|
||||
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf:
|
||||
_safe_extract_zip(zf, skill_dir)
|
||||
|
||||
meta: SkillMetadata = {
|
||||
"org": org,
|
||||
"name": name,
|
||||
"version": version,
|
||||
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
(skill_dir / _META_FILENAME).write_text(json.dumps(meta, indent=2))
|
||||
return skill_dir
|
||||
|
||||
def list_cached(self) -> list[SkillMetadata]:
|
||||
"""Return metadata for every cached skill."""
|
||||
results: list[SkillMetadata] = []
|
||||
if not self._root.exists():
|
||||
return results
|
||||
for org_dir in sorted(self._root.iterdir()):
|
||||
if not org_dir.is_dir():
|
||||
continue
|
||||
for skill_dir in sorted(org_dir.iterdir()):
|
||||
meta_file = skill_dir / _META_FILENAME
|
||||
if meta_file.exists():
|
||||
try:
|
||||
results.append(json.loads(meta_file.read_text()))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
_logger.debug(
|
||||
"Skipping malformed cache entry: %s",
|
||||
meta_file,
|
||||
exc_info=True,
|
||||
)
|
||||
return results
|
||||
|
||||
def invalidate(self, org: str, name: str) -> bool:
|
||||
"""Remove a cached skill.
|
||||
|
||||
Returns:
|
||||
True if the cache entry existed and was removed, False otherwise.
|
||||
"""
|
||||
skill_dir = self._skill_dir(org, name)
|
||||
if skill_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(skill_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe extraction for Python < 3.12."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in tf.getmembers():
|
||||
member_path = (dest / member.name).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member.name!r}")
|
||||
tf.extractall(dest) # noqa: S202
|
||||
|
||||
|
||||
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
"""Path-traversal-safe ZIP extraction."""
|
||||
dest_resolved = dest.resolve()
|
||||
for member in zf.namelist():
|
||||
member_path = (dest / member).resolve()
|
||||
if not member_path.is_relative_to(dest_resolved):
|
||||
raise ValueError(f"Blocked path traversal attempt: {member!r}")
|
||||
zf.extractall(dest) # noqa: S202
|
||||
@@ -78,6 +78,10 @@ class SkillFrontmatter(BaseModel):
|
||||
alias="allowed-tools",
|
||||
description="Pre-approved tool names the skill may use, parsed from a space-delimited string in frontmatter.",
|
||||
)
|
||||
version: str | None = Field(
|
||||
default=None,
|
||||
description="Semantic version of the skill, e.g. '1.0.0'. Optional for local skills.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
223
lib/crewai/src/crewai/skills/registry.py
Normal file
223
lib/crewai/src/crewai/skills/registry.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Registry reference resolution for the Agent Skills standard.
|
||||
|
||||
Handles @org/skill-name references, local-first resolution, and downloads
|
||||
via the CrewAI+ API with a global cache at ~/.crewai/skills/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillNotCachedError(Exception):
|
||||
"""Raised when a registry skill is not cached and the environment is non-interactive."""
|
||||
|
||||
def __init__(self, ref: str) -> None:
|
||||
super().__init__(
|
||||
f"Skill {ref!r} is not cached locally. "
|
||||
f"Run `crewai skill install {ref}` to install it first."
|
||||
)
|
||||
self.ref = ref
|
||||
|
||||
|
||||
def is_registry_ref(value: Any) -> bool:
|
||||
"""Return True if *value* looks like a registry reference (@org/name)."""
|
||||
return isinstance(value, str) and value.startswith("@")
|
||||
|
||||
|
||||
def parse_registry_ref(ref: str) -> tuple[str, str]:
|
||||
"""Parse '@org/skill-name' into (org, name).
|
||||
|
||||
Args:
|
||||
ref: A registry reference, e.g. '@acme/my-skill'.
|
||||
|
||||
Returns:
|
||||
A (org, name) tuple.
|
||||
|
||||
Raises:
|
||||
ValueError: If the reference format is invalid.
|
||||
"""
|
||||
if not ref.startswith("@"):
|
||||
raise ValueError(f"Registry reference must start with '@', got: {ref!r}")
|
||||
without_at = ref[1:]
|
||||
if without_at.count("/") != 1:
|
||||
raise ValueError(
|
||||
f"Registry reference must be in '@org/name' format, got: {ref!r}"
|
||||
)
|
||||
org, name = without_at.split("/", 1)
|
||||
if (
|
||||
not org
|
||||
or not name
|
||||
or org.startswith(".")
|
||||
or name.startswith(".")
|
||||
or "/" in org
|
||||
or "/" in name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Registry reference org and name must be single, non-empty path "
|
||||
f"segments (no '..' or leading dots), got: {ref!r}"
|
||||
)
|
||||
return org, name
|
||||
|
||||
|
||||
def _is_noninteractive() -> bool:
|
||||
"""Return True in CI or explicitly non-interactive environments."""
|
||||
import os
|
||||
|
||||
return (
|
||||
os.environ.get("CI") == "1"
|
||||
or os.environ.get("CREWAI_NONINTERACTIVE") == "1"
|
||||
or not sys.stdin.isatty()
|
||||
)
|
||||
|
||||
|
||||
def resolve_registry_ref(
|
||||
ref: str,
|
||||
source: Any = None,
|
||||
) -> Skill: # type: ignore[name-defined] # noqa: F821
|
||||
"""Resolve a registry reference to a Skill object.
|
||||
|
||||
Resolution order:
|
||||
1. ./skills/{name}/ in the current working directory (project-local)
|
||||
2. ~/.crewai/skills/{org}/{name}/ (global cache)
|
||||
3. Download from registry (interactive only; raises SkillNotCachedError in CI)
|
||||
|
||||
Args:
|
||||
ref: A registry reference, e.g. '@acme/my-skill'.
|
||||
source: Optional source object passed through to skill loaders (for events).
|
||||
|
||||
Returns:
|
||||
A Skill loaded at INSTRUCTIONS disclosure level.
|
||||
|
||||
Raises:
|
||||
SkillNotCachedError: When not cached and running in non-interactive mode.
|
||||
"""
|
||||
from crewai.skills.loader import activate_skill
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
org, name = parse_registry_ref(ref)
|
||||
|
||||
# 1. Project-local: ./skills/{name}/
|
||||
local_path = Path.cwd() / "skills" / name
|
||||
if local_path.is_dir() and (local_path / "SKILL.md").exists():
|
||||
try:
|
||||
skill = load_skill_metadata(local_path)
|
||||
return activate_skill(skill, source=source)
|
||||
except Exception:
|
||||
_logger.debug("Failed to load local skill at %s", local_path, exc_info=True)
|
||||
|
||||
# 2. Global cache
|
||||
cache = SkillCacheManager()
|
||||
cached_path = cache.get_cached_path(org, name)
|
||||
if cached_path is not None and (cached_path / "SKILL.md").exists():
|
||||
try:
|
||||
skill = load_skill_metadata(cached_path)
|
||||
return activate_skill(skill, source=source)
|
||||
except Exception:
|
||||
_logger.debug(
|
||||
"Failed to load cached skill at %s", cached_path, exc_info=True
|
||||
)
|
||||
|
||||
# 3. Download
|
||||
if _is_noninteractive():
|
||||
raise SkillNotCachedError(ref)
|
||||
|
||||
return download_skill(org, name, source=source)
|
||||
|
||||
|
||||
def download_skill(
|
||||
org: str,
|
||||
name: str,
|
||||
source: Any = None,
|
||||
) -> Skill: # type: ignore[name-defined] # noqa: F821
|
||||
"""Download a skill from the registry and store it in the cache.
|
||||
|
||||
Args:
|
||||
org: Organisation slug.
|
||||
name: Skill name.
|
||||
source: Optional source for event emission.
|
||||
|
||||
Returns:
|
||||
The downloaded Skill at INSTRUCTIONS level.
|
||||
"""
|
||||
from crewai.skills.loader import activate_skill
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
ref = f"@{org}/{name}"
|
||||
|
||||
try:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.skill_events import (
|
||||
SkillDownloadCompletedEvent,
|
||||
SkillDownloadStartedEvent,
|
||||
)
|
||||
|
||||
_has_events = True
|
||||
except ImportError:
|
||||
_has_events = False
|
||||
|
||||
if _has_events:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDownloadStartedEvent(
|
||||
registry_ref=ref,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
from crewai_core.plus_api import PlusAPI
|
||||
|
||||
api = PlusAPI()
|
||||
response = api.get_skill(org, name)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Failed to download skill {ref!r} from registry: {exc}"
|
||||
) from exc
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
||||
version = data.get("latest_version") or data.get("version")
|
||||
|
||||
download_url = data.get("download_url")
|
||||
if download_url:
|
||||
dl_response = httpx.get(download_url, follow_redirects=True)
|
||||
dl_response.raise_for_status()
|
||||
archive_bytes = dl_response.content
|
||||
else:
|
||||
encoded = data.get("file", "")
|
||||
# Strip data URI prefix if present
|
||||
if "," in encoded:
|
||||
encoded = encoded.split(",", 1)[1]
|
||||
archive_bytes = base64.b64decode(encoded)
|
||||
|
||||
cache = SkillCacheManager()
|
||||
skill_dir = cache.store(org, name, version, archive_bytes)
|
||||
|
||||
if _has_events:
|
||||
crewai_event_bus.emit(
|
||||
source,
|
||||
event=SkillDownloadCompletedEvent(
|
||||
registry_ref=ref,
|
||||
version=version,
|
||||
cache_path=skill_dir,
|
||||
),
|
||||
)
|
||||
|
||||
if not (skill_dir / "SKILL.md").exists():
|
||||
raise RuntimeError(
|
||||
f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}"
|
||||
)
|
||||
skill = load_skill_metadata(skill_dir)
|
||||
return activate_skill(skill, source=source)
|
||||
@@ -113,12 +113,68 @@ def _migrate(data: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
# --- migrations in version order ---
|
||||
# if stored < Version("X.Y.Z"):
|
||||
# data.setdefault("some_field", "default")
|
||||
if stored < Version("1.14.6"):
|
||||
for entity in data.get("entities") or []:
|
||||
_backfill_discriminators(entity)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _backfill_memory_kind(value: Any) -> None:
|
||||
"""Infer ``memory_kind`` from structural fields on legacy memory dicts."""
|
||||
if not isinstance(value, dict) or "memory_kind" in value:
|
||||
return
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
value["memory_kind"] = "scope"
|
||||
else:
|
||||
value["memory_kind"] = "memory"
|
||||
|
||||
|
||||
def _backfill_source_type(source: Any) -> None:
|
||||
"""Infer ``source_type`` for legacy knowledge source dicts when possible.
|
||||
|
||||
Only StringKnowledgeSource is reliably inferrable: it stores ``content``
|
||||
as a plain string. File-based sources (CSV/PDF/Excel/JSON/docling) also
|
||||
have a ``content`` field but populate it with dicts/lists, so we leave
|
||||
those untagged and let downstream validation surface a clear error.
|
||||
"""
|
||||
if not isinstance(source, dict) or "source_type" in source:
|
||||
return
|
||||
if isinstance(source.get("content"), str):
|
||||
source["source_type"] = "string"
|
||||
return
|
||||
raise ValueError(
|
||||
"Legacy knowledge source is missing 'source_type' and could not be "
|
||||
"inferred during migration. Re-checkpoint after upgrading to 1.14.6+."
|
||||
)
|
||||
|
||||
|
||||
def _backfill_sources_on(container: Any) -> None:
|
||||
"""Apply source_type backfill to ``sources`` and ``knowledge_sources`` lists."""
|
||||
if not isinstance(container, dict):
|
||||
return
|
||||
for key in ("sources", "knowledge_sources"):
|
||||
for src in container.get(key) or []:
|
||||
_backfill_source_type(src)
|
||||
|
||||
|
||||
def _backfill_discriminators(entity: Any) -> None:
|
||||
"""Walk an entity dict and backfill discriminator fields added in 1.14.6."""
|
||||
if not isinstance(entity, dict):
|
||||
return
|
||||
_backfill_memory_kind(entity.get("memory"))
|
||||
_backfill_sources_on(entity)
|
||||
_backfill_sources_on(entity.get("knowledge"))
|
||||
for agent in entity.get("agents") or []:
|
||||
if not isinstance(agent, dict):
|
||||
continue
|
||||
_backfill_memory_kind(agent.get("memory"))
|
||||
_backfill_sources_on(agent)
|
||||
_backfill_sources_on(agent.get("knowledge"))
|
||||
|
||||
|
||||
class RuntimeState(RootModel): # type: ignore[type-arg]
|
||||
root: list[Entity]
|
||||
_provider: BaseProvider = PrivateAttr(default_factory=JsonProvider)
|
||||
|
||||
@@ -19,6 +19,15 @@ from pydantic import BeforeValidator, WithJsonSchema
|
||||
from pydantic.functional_serializers import PlainSerializer
|
||||
|
||||
|
||||
_TRUSTED_DESERIALIZE_VALUES = frozenset({"1", "true", "yes"})
|
||||
|
||||
|
||||
def _trusted_deserialize() -> bool:
|
||||
"""Return True only if ``CREWAI_DESERIALIZE_CALLBACKS`` is an explicit yes."""
|
||||
raw = os.environ.get("CREWAI_DESERIALIZE_CALLBACKS", "")
|
||||
return raw.strip().lower() in _TRUSTED_DESERIALIZE_VALUES
|
||||
|
||||
|
||||
def _is_non_roundtrippable(fn: object) -> bool:
|
||||
"""Return ``True`` if *fn* cannot survive a serialize/deserialize round-trip.
|
||||
|
||||
@@ -76,7 +85,7 @@ def string_to_callable(value: Any) -> Callable[..., Any]:
|
||||
raise ValueError(
|
||||
f"Invalid callback path {value!r}: expected 'module.name' format"
|
||||
)
|
||||
if not os.environ.get("CREWAI_DESERIALIZE_CALLBACKS"):
|
||||
if not _trusted_deserialize():
|
||||
raise ValueError(
|
||||
f"Refusing to resolve callback path {value!r}: "
|
||||
"set CREWAI_DESERIALIZE_CALLBACKS=1 to allow. "
|
||||
@@ -150,3 +159,78 @@ SerializableCallable = Annotated[
|
||||
PlainSerializer(callable_to_string, return_type=str, when_used="json"),
|
||||
WithJsonSchema({"type": "string"}),
|
||||
]
|
||||
|
||||
|
||||
def _instance_to_dotted_path(value: Any) -> str:
|
||||
"""Serialize an instance to a dotted path naming its class."""
|
||||
if inspect.isclass(value):
|
||||
module = getattr(value, "__module__", "<unknown>")
|
||||
qualname = getattr(
|
||||
value, "__qualname__", getattr(value, "__name__", str(type(value)))
|
||||
)
|
||||
raise ValueError(f"Expected an instance, got class {module}.{qualname}.")
|
||||
cls = type(value)
|
||||
if cls.__module__ == "builtins":
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: builtin values are not "
|
||||
"checkpointable instances."
|
||||
)
|
||||
module = getattr(cls, "__module__", None)
|
||||
qualname = getattr(cls, "__qualname__", None)
|
||||
if module is None or qualname is None:
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: class missing __module__ or __qualname__. "
|
||||
"Use a module-level class for checkpointable instances."
|
||||
)
|
||||
if qualname.endswith("<lambda>") or "<locals>" in qualname:
|
||||
raise ValueError(
|
||||
f"Cannot serialize {value!r}: class defined in <locals>. "
|
||||
"Use a module-level class for checkpointable instances."
|
||||
)
|
||||
return f"{module}.{qualname}"
|
||||
|
||||
|
||||
def _dotted_path_to_instance(value: Any) -> Any:
|
||||
"""Resolve a dotted path to a class and instantiate it with no args.
|
||||
|
||||
If *value* is already a non-string object it is returned as-is.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
if inspect.isclass(value):
|
||||
raise ValueError(
|
||||
f"Expected an instance or dotted path string, got class "
|
||||
f"{getattr(value, '__module__', '<unknown>')}."
|
||||
f"{getattr(value, '__qualname__', getattr(value, '__name__', ''))}."
|
||||
)
|
||||
if type(value).__module__ == "builtins":
|
||||
raise ValueError(
|
||||
f"Expected an instance of a user-defined class or dotted "
|
||||
f"path string, got builtin value {value!r}."
|
||||
)
|
||||
return value
|
||||
if "." not in value:
|
||||
raise ValueError(
|
||||
f"Invalid provider path {value!r}: expected 'module.name' format"
|
||||
)
|
||||
if not _trusted_deserialize():
|
||||
raise ValueError(
|
||||
f"Refusing to resolve provider path {value!r}: "
|
||||
"set CREWAI_DESERIALIZE_CALLBACKS=1 to allow. "
|
||||
"Only enable this for trusted checkpoint data."
|
||||
)
|
||||
cls = _resolve_dotted_path(value)
|
||||
if not inspect.isclass(cls):
|
||||
raise ValueError(
|
||||
f"Invalid provider path {value!r}: expected a class, got "
|
||||
f"{type(cls).__name__}"
|
||||
)
|
||||
try:
|
||||
return cls()
|
||||
except TypeError as exc:
|
||||
raise ValueError(
|
||||
f"Cannot reinstantiate {value!r} with no arguments: {exc}. "
|
||||
"Only no-arg constructors are checkpointable; rebuild the "
|
||||
"instance manually and assign it after restore."
|
||||
) from exc
|
||||
|
||||
@@ -25,10 +25,16 @@ def _reset_flow_memory(flow: Flow[Any]) -> None:
|
||||
try:
|
||||
if hasattr(mem, "reset"):
|
||||
mem.reset()
|
||||
elif hasattr(mem, "_memory") and hasattr(mem._memory, "reset"):
|
||||
elif hasattr(mem, "_memory") and mem._memory is not None:
|
||||
mem._memory.reset()
|
||||
except (FileNotFoundError, OSError):
|
||||
except FileNotFoundError:
|
||||
# Storage directory was never created — nothing to reset.
|
||||
pass
|
||||
except OSError as exc:
|
||||
click.echo(f"Memory reset skipped: storage I/O error ({exc}).", err=True)
|
||||
except RuntimeError as exc:
|
||||
# Restored MemoryScope/MemorySlice without a rebound Memory.
|
||||
click.echo(f"Memory reset skipped: {exc}", err=True)
|
||||
|
||||
|
||||
def reset_memories_command(
|
||||
|
||||
116
lib/crewai/tests/skills/test_cache.py
Normal file
116
lib/crewai/tests/skills/test_cache.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for SkillCacheManager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
def _make_tar_gz(files: dict[str, str]) -> bytes:
|
||||
"""Build an in-memory .tar.gz containing the given filename → content mapping."""
|
||||
buf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
|
||||
gz_buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=gz_buf, mode="w") as tf:
|
||||
for name, content in files.items():
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
gz.write(gz_buf.getvalue())
|
||||
buf.seek(0)
|
||||
# Re-create properly: gzip wrapping a tar stream
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode="w:gz") as tf:
|
||||
for name, content in files.items():
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class TestSkillCacheManager:
|
||||
def test_get_cached_path_missing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
assert cache.get_cached_path("acme", "my-skill") is None
|
||||
|
||||
def test_store_and_retrieve(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "---\nname: my-skill\n---\nHello"})
|
||||
dest = cache.store("acme", "my-skill", "1.0.0", archive)
|
||||
|
||||
assert dest.is_dir()
|
||||
assert (dest / "SKILL.md").exists()
|
||||
|
||||
retrieved = cache.get_cached_path("acme", "my-skill")
|
||||
assert retrieved == dest
|
||||
|
||||
def test_store_writes_metadata(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "content"})
|
||||
dest = cache.store("acme", "my-skill", "2.3.4", archive)
|
||||
|
||||
meta_file = dest / ".crewai_meta.json"
|
||||
assert meta_file.exists()
|
||||
meta = json.loads(meta_file.read_text())
|
||||
assert meta["org"] == "acme"
|
||||
assert meta["name"] == "my-skill"
|
||||
assert meta["version"] == "2.3.4"
|
||||
assert "installed_at" in meta
|
||||
|
||||
def test_store_overwrites_previous_version(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive_v1 = _make_tar_gz({"SKILL.md": "v1", "extra.txt": "old"})
|
||||
cache.store("acme", "my-skill", "1.0.0", archive_v1)
|
||||
|
||||
archive_v2 = _make_tar_gz({"SKILL.md": "v2"})
|
||||
dest = cache.store("acme", "my-skill", "2.0.0", archive_v2)
|
||||
|
||||
# Old file should be gone
|
||||
assert not (dest / "extra.txt").exists()
|
||||
assert (dest / "SKILL.md").read_text() == "v2"
|
||||
|
||||
meta = json.loads((dest / ".crewai_meta.json").read_text())
|
||||
assert meta["version"] == "2.0.0"
|
||||
|
||||
def test_list_cached_empty(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
assert cache.list_cached() == []
|
||||
|
||||
def test_list_cached(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
cache.store("acme", "skill-a", "1.0.0", archive)
|
||||
cache.store("acme", "skill-b", "0.1.0", archive)
|
||||
cache.store("other-org", "skill-c", None, archive)
|
||||
|
||||
entries = cache.list_cached()
|
||||
names = {e["name"] for e in entries}
|
||||
assert names == {"skill-a", "skill-b", "skill-c"}
|
||||
|
||||
def test_invalidate_existing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
cache.store("acme", "my-skill", "1.0.0", archive)
|
||||
|
||||
removed = cache.invalidate("acme", "my-skill")
|
||||
assert removed is True
|
||||
assert cache.get_cached_path("acme", "my-skill") is None
|
||||
|
||||
def test_invalidate_missing(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
removed = cache.invalidate("acme", "ghost-skill")
|
||||
assert removed is False
|
||||
|
||||
def test_store_version_none(self, tmp_path: Path) -> None:
|
||||
cache = SkillCacheManager(cache_root=tmp_path)
|
||||
archive = _make_tar_gz({"SKILL.md": "x"})
|
||||
dest = cache.store("acme", "my-skill", None, archive)
|
||||
meta = json.loads((dest / ".crewai_meta.json").read_text())
|
||||
assert meta["version"] is None
|
||||
32
lib/crewai/tests/skills/test_models_version.py
Normal file
32
lib/crewai/tests/skills/test_models_version.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for the version field added to SkillFrontmatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from crewai.skills.models import SkillFrontmatter
|
||||
|
||||
|
||||
class TestSkillFrontmatterVersion:
|
||||
def test_version_defaults_to_none(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_can_be_set(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.2.3")
|
||||
assert fm.version == "1.2.3"
|
||||
|
||||
def test_existing_frontmatter_without_version_still_valid(self) -> None:
|
||||
"""Backward compat: existing SKILL.md files without version must still parse."""
|
||||
fm = SkillFrontmatter(name="old-skill", description="Old skill without version.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_is_optional_string(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="Desc.", version=None)
|
||||
assert fm.version is None
|
||||
|
||||
def test_frontmatter_is_frozen(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.0.0")
|
||||
with pytest.raises(ValidationError):
|
||||
fm.version = "2.0.0" # type: ignore[misc]
|
||||
129
lib/crewai/tests/skills/test_registry.py
Normal file
129
lib/crewai/tests/skills/test_registry.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for SkillRegistry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.skills.registry import (
|
||||
SkillNotCachedError,
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
)
|
||||
|
||||
|
||||
class TestIsRegistryRef:
|
||||
def test_at_prefixed(self) -> None:
|
||||
assert is_registry_ref("@acme/my-skill") is True
|
||||
|
||||
def test_plain_string(self) -> None:
|
||||
assert is_registry_ref("my-skill") is False
|
||||
|
||||
def test_path_like_string(self) -> None:
|
||||
assert is_registry_ref("./skills/my-skill") is False
|
||||
|
||||
def test_non_string(self) -> None:
|
||||
assert is_registry_ref(None) is False
|
||||
assert is_registry_ref(42) is False
|
||||
assert is_registry_ref(Path("something")) is False
|
||||
|
||||
|
||||
class TestParseRegistryRef:
|
||||
def test_valid(self) -> None:
|
||||
assert parse_registry_ref("@acme/my-skill") == ("acme", "my-skill")
|
||||
|
||||
def test_valid_with_dashes(self) -> None:
|
||||
assert parse_registry_ref("@my-org/cool-skill") == ("my-org", "cool-skill")
|
||||
|
||||
def test_missing_at(self) -> None:
|
||||
with pytest.raises(ValueError, match="must start with '@'"):
|
||||
parse_registry_ref("acme/my-skill")
|
||||
|
||||
def test_missing_slash(self) -> None:
|
||||
with pytest.raises(ValueError, match="'@org/name' format"):
|
||||
parse_registry_ref("@acme-skill")
|
||||
|
||||
def test_empty_org(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
parse_registry_ref("@/my-skill")
|
||||
|
||||
def test_empty_name(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
parse_registry_ref("@acme/")
|
||||
|
||||
|
||||
class TestResolveRegistryRef:
|
||||
"""Test resolution order and CI mode behaviour."""
|
||||
|
||||
def _make_skill_dir(self, base: Path, name: str) -> Path:
|
||||
"""Write a minimal SKILL.md into base/name/."""
|
||||
skill_dir = base / name
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Test skill.\n---\n\nInstructions."
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
def test_resolves_project_local(self, tmp_path: Path) -> None:
|
||||
"""Local ./skills/{name}/ takes priority over cache."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
self._make_skill_dir(skills_dir, "my-skill")
|
||||
|
||||
# Mock SkillCacheManager to return None (not cached) so only local is hit
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/my-skill")
|
||||
|
||||
assert skill.name == "my-skill"
|
||||
|
||||
def test_raises_in_ci_when_not_cached(self, tmp_path: Path) -> None:
|
||||
"""In CI mode, raise SkillNotCachedError if no local or cached copy."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=True),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
with pytest.raises(SkillNotCachedError) as exc_info:
|
||||
resolve_registry_ref("@acme/ghost-skill")
|
||||
assert "@acme/ghost-skill" in str(exc_info.value)
|
||||
|
||||
def test_resolves_from_cache(self, tmp_path: Path) -> None:
|
||||
"""Falls back to global cache when no project-local skill exists."""
|
||||
cache_dir = tmp_path / "acme" / "cached-skill"
|
||||
cache_dir.mkdir(parents=True)
|
||||
(cache_dir / "SKILL.md").write_text(
|
||||
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
|
||||
)
|
||||
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cached_path.return_value = cache_dir
|
||||
|
||||
# tmp_path has no ./skills/ directory
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/cached-skill")
|
||||
|
||||
assert skill.name == "cached-skill"
|
||||
|
||||
def test_skill_not_cached_error_contains_ref(self) -> None:
|
||||
err = SkillNotCachedError("@foo/bar")
|
||||
assert "@foo/bar" in str(err)
|
||||
assert err.ref == "@foo/bar"
|
||||
@@ -7,20 +7,87 @@ durability, input history tracking, and integration with flow machinery.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.flow import Flow, flow_config, listen, start
|
||||
from crewai.flow.async_feedback.providers import ConsoleProvider
|
||||
from crewai.flow.flow import FlowState
|
||||
from crewai.flow.input_provider import InputProvider, InputResponse
|
||||
from crewai.flow.persistence.base import FlowPersistence
|
||||
|
||||
|
||||
# ── Test helpers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _SaveCall:
|
||||
"""Lightweight stand-in for ``MagicMock.call_args`` entries."""
|
||||
|
||||
__slots__ = ("args", "kwargs")
|
||||
|
||||
def __init__(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class _SaveStateRecorder:
|
||||
"""Callable that records each ``save_state`` invocation."""
|
||||
|
||||
def __init__(self, owner: RecordingPersistence) -> None:
|
||||
self._owner = owner
|
||||
self.call_args_list: list[_SaveCall] = []
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
flow_uuid: str,
|
||||
method_name: str,
|
||||
state_data: dict[str, Any] | BaseModel,
|
||||
) -> None:
|
||||
snapshot: dict[str, Any] | BaseModel
|
||||
if isinstance(state_data, BaseModel):
|
||||
snapshot = state_data.model_copy(deep=True)
|
||||
else:
|
||||
snapshot = copy.deepcopy(state_data)
|
||||
self.call_args_list.append(
|
||||
_SaveCall((flow_uuid, method_name, snapshot), {})
|
||||
)
|
||||
self._owner._states[flow_uuid] = snapshot
|
||||
|
||||
|
||||
class RecordingPersistence(FlowPersistence):
|
||||
"""In-memory FlowPersistence that records ``save_state`` invocations."""
|
||||
|
||||
persistence_type: str = "RecordingPersistence"
|
||||
|
||||
def model_post_init(self, _: Any) -> None:
|
||||
object.__setattr__(self, "_states", {})
|
||||
object.__setattr__(self, "save_state", _SaveStateRecorder(self))
|
||||
|
||||
def init_db(self) -> None:
|
||||
return None
|
||||
|
||||
def save_state( # type: ignore[no-redef]
|
||||
self,
|
||||
flow_uuid: str,
|
||||
method_name: str,
|
||||
state_data: dict[str, Any] | BaseModel,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def load_state(self, flow_uuid: str) -> dict[str, Any] | None:
|
||||
snapshot = self._states.get(flow_uuid)
|
||||
if snapshot is None:
|
||||
return None
|
||||
if isinstance(snapshot, BaseModel):
|
||||
return snapshot.model_copy(deep=True).model_dump()
|
||||
return copy.deepcopy(snapshot)
|
||||
|
||||
|
||||
class MockInputProvider:
|
||||
"""Mock input provider that returns pre-configured responses."""
|
||||
|
||||
@@ -436,8 +503,7 @@ class TestAskCheckpoint:
|
||||
|
||||
def test_ask_checkpoints_state_before_waiting(self) -> None:
|
||||
"""State is saved to persistence before waiting for input."""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class TestFlow(Flow):
|
||||
input_provider = MockInputProvider(["answer"])
|
||||
@@ -480,8 +546,7 @@ class TestAskCheckpoint:
|
||||
server crashes while waiting for input, previously gathered data
|
||||
is safe.
|
||||
"""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class GatherFlow(Flow):
|
||||
input_provider = MockInputProvider(["AI", "detailed"])
|
||||
@@ -678,8 +743,7 @@ class TestAskIntegration:
|
||||
|
||||
def test_ask_with_state_persistence_recovery(self) -> None:
|
||||
"""Ask checkpoints state so previously gathered values survive."""
|
||||
mock_persistence = MagicMock()
|
||||
mock_persistence.load_state.return_value = None
|
||||
mock_persistence = RecordingPersistence()
|
||||
|
||||
class RecoverableFlow(Flow):
|
||||
input_provider = MockInputProvider(["AI", "detailed"])
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.5"
|
||||
__version__ = "1.14.6a1"
|
||||
|
||||
@@ -187,6 +187,8 @@ exclude-newer = "3 days"
|
||||
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
|
||||
# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+.
|
||||
# authlib <1.6.11 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage).
|
||||
# pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected.
|
||||
# paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core.
|
||||
# litellm 1.83.8+ hard-pins openai==2.24.0, missing openai.types.responses used by crewai;
|
||||
# override to >=2.30.0 (the version litellm 1.83.7 used) until upstream relaxes the pin.
|
||||
override-dependencies = [
|
||||
@@ -205,6 +207,8 @@ override-dependencies = [
|
||||
"gitpython>=3.1.50,<4",
|
||||
"langsmith>=0.8.0,<1",
|
||||
"authlib>=1.6.11",
|
||||
"pip>=26.1.1",
|
||||
"paramiko>=5.0.0",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
16
uv.lock
generated
16
uv.lock
generated
@@ -13,7 +13,7 @@ resolution-markers = [
|
||||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-05-16T15:32:24.373474Z"
|
||||
exclude-newer = "2026-05-17T14:20:01.778505Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
@@ -34,7 +34,9 @@ overrides = [
|
||||
{ name = "langsmith", specifier = ">=0.8.0,<1" },
|
||||
{ name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" },
|
||||
{ name = "openai", specifier = ">=2.30.0,<3" },
|
||||
{ name = "paramiko", specifier = ">=5.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pip", specifier = ">=26.1.1" },
|
||||
{ name = "pypdf", specifier = ">=6.10.2,<7" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
@@ -5788,7 +5790,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "paramiko"
|
||||
version = "4.0.0"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
@@ -5796,9 +5798,9 @@ dependencies = [
|
||||
{ name = "invoke" },
|
||||
{ name = "pynacl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6060,11 +6062,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.1"
|
||||
version = "26.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user