Compare commits

..

39 Commits

Author SHA1 Message Date
Greyson LaLonde
0e9167dec3 fix: reject class-typed embedders at serialize time, drop unused validator 2026-05-21 03:20:32 +08:00
Greyson LaLonde
1eb2326e8a fix: normalize CREWAI_DESERIALIZE_CALLBACKS to explicit allowlist 2026-05-21 03:13:32 +08:00
Greyson LaLonde
a0e5d91364 fix: reject classes and builtins in _dotted_path_to_instance for symmetry 2026-05-21 03:08:47 +08:00
Greyson LaLonde
c6643d4071 fix: apply source_type resolver to Crew/Agent knowledge_sources 2026-05-21 02:57:31 +08:00
Greyson LaLonde
0d5c2c81e0 Merge branch 'main' into fix/runtime-state-serialization 2026-05-21 02:47:56 +08:00
Greyson LaLonde
2346f12c43 fix: prefer crew Memory over fresh instance when rebinding views 2026-05-21 02:46:23 +08:00
Greyson LaLonde
dc047743b8 fix: surface clear error when dotted-path provider needs ctor args 2026-05-21 02:26:57 +08:00
Greyson LaLonde
163b3b592d fix: rebind agent memory view on standalone agent checkpoint restore 2026-05-21 02:23:04 +08:00
Greyson LaLonde
83ba64c334 fix: backfill source_type on nested agent knowledge sources too 2026-05-21 02:18:42 +08:00
alex-clawd
418afd29e7 feat: Skills Repository — registry, cache, CLI, and SDK integration (#5867)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
* feat: add Skills Repository — registry, cache, CLI, and SDK integration

Adds a Skills Repository feature allowing users to publish, install,
and use skills from the CrewAI registry with @org/skill-name refs.

## What's New

### SDK (lib/crewai/)
- SkillFrontmatter: added optional 'version' field (backward compatible)
- SkillCacheManager: manages ~/.crewai/skills/{org}/{name}/ with
  .crewai_meta.json tracking, path-traversal-safe tar extraction
- SkillRegistry: parse @org/skill-name refs, local-first resolution
  (./skills/ > cache > download), interactive prompt on first use,
  CI-mode guard (CREWAI_NONINTERACTIVE/CI env vars)
- Agent.skills and Crew.skills widened to accept str refs (@org/name)
- set_skills() resolves registry refs with org-prefixed dedup keys
- New events: SkillDownloadStartedEvent, SkillDownloadCompletedEvent

### CLI (lib/cli/)
- crewai skill create <name> — context-aware (project vs standalone)
- crewai skill install @org/name — downloads to ./skills/ or cache
- crewai skill publish — ZIP + upload to org registry
- crewai skill list — show installed skills

### PlusAPI (lib/crewai-core/)
- Added SKILLS_RESOURCE, get_skill(), publish_skill(), list_skills()

### Scaffolding
- crew and flow templates now include skills/ directory

### Tests
- 91 SDK skill tests + 15 CLI skill tests, all passing

* fix: address all CI failures and CodeRabbit review comments

Lint:
- Remove unused imports (click, pytest, json)
- Replace try-except-pass with logging (S110)
- Fix unprotected zipfile.extractall (S202)

Security:
- Path traversal: startswith → is_relative_to for tar extraction
- Add path traversal protection to ZIP extraction via _safe_extract_zip
- Both cache.py and CLI main.py hardened

Type checker:
- Fix import path: crewai.events.event_bus (not crewai_event_bus)
- Remove unused type: ignore comments
- Fix type mismatches in set_skills() variable types

Code quality:
- Fix f-string interpolation in SkillNotCachedError
- Use ValidationError instead of Exception in test

* style: ruff format + autofix remaining lint errors

* refactor: reuse SDK parser and SkillCacheManager in CLI

- _parse_frontmatter() now delegates to crewai.skills.parser.parse_frontmatter
  when available, with a minimal fallback for CLI-only installs
- install() global cache path now reuses SkillCacheManager.store() instead
  of duplicating metadata writing logic

* refactor: add _print_current_organization to SkillCommand (matches ToolCommand pattern)

* fix: write .crewai_meta.json in fallback install path

CodeRabbit caught that the ImportError fallback in install() didn't write
cache metadata, making skills invisible to 'crewai skill list'.

* fix: tighten @org/name ref validation to prevent path traversal

Reject refs with multiple slashes (@org/a/b), dot segments (@../skill),
or leading dots in org/name. Applied to both CLI install() and SDK
parse_registry_ref() so the contract is enforced consistently.

* fix: update test assertions to match tightened error messages

* fix: align OSS client with AMP API contract

- download_skill(): fetch download_url (presigned URL) instead of
  expecting inline base64. Falls back to 'file' field for compat.
- Read 'latest_version' field, fall back to 'version'
- Same fixes applied to CLI install() command

* fix: publish as tar.gz (matches AMP content_type validation) + add zip fallback to SDK cache

CLI publish:
- _build_skill_zip → _build_skill_tarball (tar.gz format)
- Content type: application/x-gzip (matches SkillVersion validation)

SDK cache:
- store() now tries tar.gz first, falls back to zip extraction
- Added _safe_extract_zip for path-traversal-safe zip handling
- Both formats work for download/install regardless of server format

---------

Co-authored-by: João Moura <joaomdmoura@gmail.com>
2026-05-20 14:38:25 -03:00
Greyson LaLonde
fc480409bd fix: warn on memory reset I/O and unbound view errors instead of silent pass 2026-05-21 01:13:46 +08:00
Greyson LaLonde
15a423ad3c fix: rebind MemoryScope/MemorySlice to fresh Memory after checkpoint restore 2026-05-21 01:09:30 +08:00
Greyson LaLonde
c37afab1ff fix: snapshot RecordingPersistence state and guard isclass formatting 2026-05-21 01:06:24 +08:00
Greyson LaLonde
f385b91a63 fix: swallow RuntimeError from unbound MemoryScope in reset 2026-05-21 01:02:47 +08:00
Greyson LaLonde
0991f7994a fix: guard mem._memory access in reset_memories for new None default 2026-05-21 00:53:14 +08:00
Greyson LaLonde
3ceb9a287a fix: bot review follow-ups for serialization hardening
- Reject classes and builtin values in _instance_to_dotted_path
- Require classes in _dotted_path_to_instance
- Drop unused SerializableInstance alias
- Raise on unknown FlowPersistence types in _serialize_persistence
- Gate Knowledge.embedder provider_class restore behind
  CREWAI_DESERIALIZE_CALLBACKS
- Raise on unknown source_type tags in _resolve_knowledge_sources
- Tighten _backfill_source_type: only infer 'string' when content is
  str; raise otherwise so legacy file-based sources fail loudly
- Add BeforeValidator(_ensure_memory_kind) to Crew/Agent/Flow memory
  fields so legacy dict configs get the discriminator at construction
- Default MemoryScope/MemorySlice._memory to None; add _require_memory()
  helper and route all internal accesses through it
- Convert test_flow_ask persistence mocks to RecordingPersistence
2026-05-21 00:47:40 +08:00
Greyson LaLonde
0f3a57b3b9 fix: round-trip safety for input_provider, memory scopes, embedder class
- input_provider: enforce InputProvider protocol via dedicated
  validator/serializer; reject non-class dotted paths in
  _dotted_path_to_instance
- MemoryScope/MemorySlice: allow restore without live Memory; expose
  bind() to reattach the dependency post-restore
- Knowledge.embedder: add BeforeValidator that resolves provider_class
  dotted paths back to a BaseEmbeddingsProvider subclass
2026-05-21 00:30:14 +08:00
Greyson LaLonde
b07c1439a3 fix: backfill legacy discriminators and add source validation context 2026-05-21 00:22:53 +08:00
Greyson LaLonde
97e959cb0c fix: raise on unrecognized embedder shape in serializer 2026-05-21 00:17:52 +08:00
Greyson LaLonde
752d9b45d6 fix: harden RuntimeState serialization across entity fields
Adds missing serializers, discriminators, and exclude markers on entity
fields that previously crashed model_dump_json or restored ambiguously:

- Flow.persistence: add _serialize_persistence; drop | Any escape hatch
- Flow.input_provider: SerializableInstance dotted-path round-trip
- BaseAgent.agent_executor: add _serialize_executor_ref
- BaseAgent.tools_handler / cache_handler: exclude=True
- Memory / MemoryScope / MemorySlice: memory_kind Literal discriminator
- Knowledge.storage / .embedder: exclude live client, serialize spec
- BaseKnowledgeSource subclasses: source_type Literal + dict-resolver
- BaseKnowledgeSource.storage / chunk_embeddings: exclude=True
2026-05-21 00:12:20 +08:00
Greyson LaLonde
7cc1a7bb41 fix(deps): bump pip and paramiko to drop pip-audit ignores
Some checks failed
Build uv cache / build-cache (3.10) (push) Waiting to run
Build uv cache / build-cache (3.11) (push) Waiting to run
Build uv cache / build-cache (3.12) (push) Waiting to run
Build uv cache / build-cache (3.13) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
OSV no longer flags pip 26.1.1 (GHSA-58qw-9mgm-455v) or paramiko
5.0.0 (GHSA-r374-rxx8-8654), so override both to those minimums
and remove the corresponding --ignore-vuln entries. paramiko is
pulled in transitively via composio-core.
2026-05-20 22:33:43 +08:00
Greyson LaLonde
09ffe87fbb ci: ignore pip-audit findings without published fixes
Adds joblib, markdown, nltk, onnx, pyjwt, torch and transformers
advisories that have no fixed version available (or are disputed)
to the pip-audit ignore list. Rationale recorded next to each ID.
2026-05-20 21:40:30 +08:00
Greyson LaLonde
14af56b74d ci: pin third-party actions to commit SHAs
Replaces version tags (e.g. astral-sh/setup-uv@v6, slackapi/slack-github-action@v2.1.0)
with full commit SHAs across every workflow. Mitigates supply-chain risk from
mutable tags.
2026-05-20 19:01:53 +08:00
Greyson LaLonde
35f693cf68 chore: tighten typing across plus_api client
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Adds typed containers for wire payloads, literal aliases for HTTP method
and log type, and Ffnal markers on resource constants. Updates
upstream returns in project_utils.py and deploy/main.py to match
the new contracts.
2026-05-20 01:43:48 +08:00
Greyson LaLonde
da15554d81 feat: generate categorized release notes for enterprise 2026-05-20 00:24:26 +08:00
Greyson LaLonde
284533464f fix: bump idna to 3.15 to address GHSA-65pc-fj4g-8rjx
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
2026-05-19 23:38:34 +08:00
Tiago Freire
024e230b2c docs: remove {" "} JSX expressions breaking <Steps> render (#5857)
## Overview

Prettier-inserted bare `{" "}` lines between sibling `<Step>` elements caused Mintlify's `<Steps>` to crash with "Cannot read properties of undefined (reading 'stepNumber')", leaving the page body blank.

### Affected pages (en/ar/ko/pt-BR):
- enterprise/guides/enable-crew-studio
- learn/llm-selection-guide
2026-05-19 10:44:53 -04:00
Greyson LaLonde
a4c90b6912 docs: update changelog and version for v1.14.5
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-05-19 03:19:40 +08:00
Greyson LaLonde
c50da7a6f2 feat: bump versions to 1.14.5 2026-05-19 03:11:26 +08:00
Irfaan Mansoori
e8aa870f90 fix: memory leak in git.py by using cached_property
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Co-authored-by: Greyson LaLonde <greyson.r.lalonde@gmail.com>
2026-05-18 21:55:57 +08:00
Greyson LaLonde
14cd81eec6 docs: update changelog and version for v1.14.5a7 2026-05-18 21:13:34 +08:00
Greyson LaLonde
a6225da326 feat: bump versions to 1.14.5a7 2026-05-18 21:08:46 +08:00
Greyson LaLonde
259d334e38 chore(devtools): skip pinning crewai-files in file-processing extra 2026-05-18 21:00:37 +08:00
Greyson LaLonde
42aa8a777c chore: deprecate function_calling_llm field 2026-05-18 20:49:11 +08:00
Heitor Carvalho
a95d26763f docs: update changelog and version for v1.14.5a6 (#5828)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-05-15 17:05:04 -03:00
Heitor Carvalho
65ec783aae feat: bump versions to 1.14.5a6 (#5827) 2026-05-15 16:51:59 -03:00
Greyson LaLonde
eefe0e42ac fix: surface streamed tool calls when available_functions is absent 2026-05-16 02:46:35 +08:00
Greyson LaLonde
75bb882911 fix(deps): bump langsmith to >=0.8.0 for GHSA-3644-q5cj-c5c7
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
2026-05-15 21:32:52 +08:00
iris-clawd
c36827b45b fix(docs/pt-BR): replace untranslated code block placeholders (#5781)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* fix(docs/pt-BR): replace untranslated code block placeholders

Replace all `# (O código não é traduzido)` and `# código não traduzido`
placeholder comments in the PT-BR docs with the actual code from the
English source files.

Files fixed:
- docs/pt-BR/concepts/flows.mdx (~15 placeholders → real code)
- docs/pt-BR/guides/flows/mastering-flow-state.mdx (~17 placeholders → real code)

Code itself is kept in English per i18n conventions. Inline # comments
within code blocks have been translated to Portuguese.

* fix(docs/pt-BR): address CodeRabbit review comments

- flows.mdx: add missing load_dotenv() call after imports
- mastering-flow-state.mdx: fix PersistentCounterFlow second-run example
  to pass inputs={"id": flow1.state.id} to kickoff(), matching the
  documented resume pattern; update comment accordingly
2026-05-13 12:23:18 -03:00
93 changed files with 7372 additions and 1073 deletions

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: ${{ matrix.python-version }}

View File

@@ -22,10 +22,10 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
uses: tibdex/github-app-token@v2
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app_id: ${{ secrets.CREWAI_TOOL_SPECS_APP_ID }}
private_key: ${{ secrets.CREWAI_TOOL_SPECS_PRIVATE_KEY }}
app-id: ${{ secrets.CREWAI_TOOL_SPECS_APP_ID }}
private-key: ${{ secrets.CREWAI_TOOL_SPECS_PRIVATE_KEY }}
- name: Checkout code
uses: actions/checkout@v4
@@ -34,7 +34,7 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.12"

View File

@@ -13,7 +13,7 @@ jobs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -41,7 +41,7 @@ jobs:
uv-main-py3.11-
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.11"

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.12"
@@ -103,7 +103,7 @@ jobs:
contents: read
steps:
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.12"

View File

@@ -10,7 +10,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: codelytv/pr-size-labeler@v1
- uses: codelytv/pr-size-labeler@095a41fca88b8764fd9e008ad269bcdb82bb38b9 # v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: "size/XS"

View File

@@ -12,7 +12,7 @@ jobs:
pr-title:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -34,7 +34,7 @@ jobs:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Build packages
run: |
@@ -63,7 +63,7 @@ jobs:
ref: ${{ inputs.release_tag || github.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.12"
@@ -159,7 +159,7 @@ jobs:
- name: Notify Slack
if: success()
uses: slackapi/slack-github-action@v2.1.0
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook

View File

@@ -13,7 +13,7 @@ jobs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -51,7 +51,7 @@ jobs:
uv-main-py${{ matrix.python-version }}-
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: ${{ matrix.python-version }}

View File

@@ -13,7 +13,7 @@ jobs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
id: filter
with:
filters: |
@@ -48,7 +48,7 @@ jobs:
uv-main-py${{ matrix.python-version }}-
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: ${{ matrix.python-version }}

View File

@@ -38,7 +38,7 @@ jobs:
uv-main-py${{ matrix.python-version }}-
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: ${{ matrix.python-version }}

View File

@@ -31,7 +31,7 @@ jobs:
uv-main-py3.11-
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
python-version: "3.11"
@@ -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

View File

@@ -4,6 +4,86 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="19 مايو 2026">
## v1.14.5
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
## ما الذي تغير
### الميزات
- إلغاء استخدام `CrewAgentExecutor`، وتعيين وكلاء الطاقم الافتراضيين إلى `AgentExecutor`
- تحسين أدوات صندوق الرمل Daytona
- إضافة معلمة بدء `restore_from_state_id`
- إضافة تسليط الضوء على `ExaSearchTool`، وإعادة تسميته من `EXASearchTool`
### إصلاحات الأخطاء
- إصلاح تسرب الذاكرة في `git.py` باستخدام `cached_property`
- عرض استدعاءات الأدوات المتدفقة عندما تكون `available_functions` غائبة
- ضمان تحميل أحداث `skills` للتتبع
- تصحيح مسار نقطة النهاية للحالة من `/{kickoff_id}/status` إلى `/status/{kickoff_id}`
- استعادة كتلة الشيفرة المفقودة في دليل التدفق الأول للغة البرتغالية (pt-BR)
- منع `result_as_answer` من إرجاع رسائل الخطأ أو الكتل المرتبطة كإجابة نهائية
- الحفاظ على مخرجات المهام عبر تفريغ الدفعات غير المتزامنة
- دائمًا استعادة `task.output_pydantic` في كتلة finally
- التعامل مع إدخال `BaseModel` في `convert_to_model`
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.5
- إضافة دليل ترقية OSS و انتقال الطاقم إلى التدفق
- توثيق متغيرات البيئة الإضافية لأدوات المطور
- إضافة وثائق لـ `TavilyGetResearch`
### إعادة الهيكلة
- استخراج واجهة سطر الأوامر إلى حزمة مستقلة `crewai-cli`
## المساهمون
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
</Update>
<Update label="18 مايو 2026">
## v1.14.5a7
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
## ما الذي تغير
### الوثائق
- تحديث سجل التغييرات والإصدار لـ v1.14.5a6
### تغييرات كسرية
- إلغاء حقل function_calling_llm
## المساهمون
@greysonlalonde, @heitorado
</Update>
<Update label="15 مايو 2026">
## v1.14.5a6
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
## ما الذي تغير
### إصلاحات الأخطاء
- إصلاح استدعاءات الأدوات المتدفقة عندما تكون available_functions غائبة
- رفع اعتماد langsmith إلى الإصدار >=0.8.0 لمعالجة GHSA-3644-q5cj-c5c7
- حل مشاكل الأماكن الشاغرة لكتل التعليمات البرمجية غير المترجمة في وثائق البرتغالية البرازيلية
### الوثائق
- إضافة وثائق لـ TavilyGetResearch
- تحديث سجل التغييرات والإصدار لـ v1.14.5a5
## المساهمون
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
</Update>
<Update label="13 مايو 2026">
## v1.14.5a5

View File

@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
from dotenv import load_dotenv
from litellm import completion
load_dotenv()
class ExampleFlow(Flow):
model = "gpt-4o-mini"

View File

@@ -146,7 +146,6 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
</Step>
{" "}
<Step title="الإجابة على الأسئلة">
أجب على أسئلة التوضيح من مساعد الطاقم لتنقيح
متطلباتك.
@@ -161,12 +160,10 @@ Crew Studio هو طريقة مبتكرة لإنشاء طواقم وكلاء ال
</Step>
{" "}
<Step title="الموافقة أو التعديل">
وافق على الخطة أو اطلب تغييرات إذا لزم الأمر.
</Step>
{" "}
<Step title="التنزيل أو النشر">
نزّل الكود للتخصيص أو انشر مباشرة على المنصة.
</Step>

View File

@@ -802,7 +802,6 @@ The tables below show a representative sample of current top-performing models a
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
</Step>
{" "}
<Step title="Identify Specialized Needs">
Determine if your crew has specific requirements (coding, reasoning, speed)
that would benefit from specialized models like **Claude 4 Sonnet** for
@@ -810,7 +809,6 @@ The tables below show a representative sample of current top-performing models a
consider fast inference providers like **Groq** alongside model selection.
</Step>
{" "}
<Step title="Implement Multi-Model Strategy">
Use different models for different agents based on their roles.
High-capability models for managers and complex tasks, efficient models for

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,86 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="May 19, 2026">
## v1.14.5
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
## What's Changed
### Features
- Deprecate `CrewAgentExecutor`, default Crew agents to `AgentExecutor`
- Improve Daytona sandbox tools
- Add `restore_from_state_id` kickoff parameter
- Add highlights to `ExaSearchTool`, rename from `EXASearchTool`
### Bug Fixes
- Fix memory leak in `git.py` by using `cached_property`
- Surface streamed tool calls when `available_functions` is absent
- Ensure `skills` loading events for traces
- Correct status endpoint path from `/{kickoff_id}/status` to `/status/{kickoff_id}`
- Restore missing code block in pt-BR first-flow guide
- Prevent `result_as_answer` from returning hook-block or error messages as final answer
- Preserve task outputs across async batch flush
- Always restore `task.output_pydantic` in finally block
- Handle `BaseModel` input in `convert_to_model`
### Documentation
- Update changelog and version for v1.14.5
- Add OSS upgrade & crew-to-flow migration guide
- Document additional env vars for devtools
- Add docs for `TavilyGetResearch`
### Refactoring
- Extract CLI into standalone `crewai-cli` package
## Contributors
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
</Update>
<Update label="May 18, 2026">
## v1.14.5a7
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
## What's Changed
### Documentation
- Update changelog and version for v1.14.5a6
### Breaking Changes
- Deprecate function_calling_llm field
## Contributors
@greysonlalonde, @heitorado
</Update>
<Update label="May 15, 2026">
## v1.14.5a6
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
## What's Changed
### Bug Fixes
- Fix streamed tool calls when available_functions is absent
- Bump langsmith dependency to version >=0.8.0 to address GHSA-3644-q5cj-c5c7
- Resolve untranslated code block placeholders in Brazilian Portuguese documentation
### Documentation
- Add documentation for TavilyGetResearch
- Update changelog and version for v1.14.5a5
## Contributors
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
</Update>
<Update label="May 13, 2026">
## v1.14.5a5

View File

@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
from dotenv import load_dotenv
from litellm import completion
load_dotenv()
class ExampleFlow(Flow):
model = "gpt-4o-mini"

View File

@@ -146,7 +146,6 @@ Here's a typical workflow for creating a crew with Crew Studio:
</Step>
{" "}
<Step title="Answer Questions">
Respond to clarifying questions from the Crew Assistant to refine your
requirements.
@@ -161,12 +160,10 @@ Here's a typical workflow for creating a crew with Crew Studio:
</Step>
{" "}
<Step title="Approve or Modify">
Approve the plan or request changes if necessary.
</Step>
{" "}
<Step title="Download or Deploy">
Download the code for customization or deploy directly to the platform.
</Step>

View File

@@ -313,9 +313,9 @@ flow1 = PersistentCounterFlow()
result1 = flow1.kickoff()
print(f"First run result: {result1}")
# Second run - state is automatically loaded
# Second run - pass the ID to load the persisted state
flow2 = PersistentCounterFlow()
result2 = flow2.kickoff()
result2 = flow2.kickoff(inputs={"id": flow1.state.id})
print(f"Second run result: {result2}") # Will be higher due to persisted state
```

View File

@@ -805,7 +805,6 @@ The tables below show a representative sample of current top-performing models a
Begin with well-established models like **GPT-4.1**, **Claude 3.7 Sonnet**, or **Gemini 2.0 Flash** that offer good performance across multiple dimensions and have extensive real-world validation.
</Step>
{" "}
<Step title="Identify Specialized Needs">
Determine if your crew has specific requirements (coding, reasoning, speed)
that would benefit from specialized models like **Claude 4 Sonnet** for
@@ -813,7 +812,6 @@ The tables below show a representative sample of current top-performing models a
consider fast inference providers like **Groq** alongside model selection.
</Step>
{" "}
<Step title="Implement Multi-Model Strategy">
Use different models for different agents based on their roles.
High-capability models for managers and complex tasks, efficient models for

View File

@@ -4,6 +4,86 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 5월 19일">
## v1.14.5
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
## 변경 사항
### 기능
- `CrewAgentExecutor` 사용 중단, 기본 Crew 에이전트를 `AgentExecutor`로 설정
- Daytona 샌드박스 도구 개선
- `restore_from_state_id` 시작 매개변수 추가
- `ExaSearchTool`에 하이라이트 추가, 이름을 `EXASearchTool`에서 변경
### 버그 수정
- `git.py`에서 `cached_property`를 사용하여 메모리 누수 수정
- `available_functions`가 없을 때 스트리밍 도구 호출 표시
- 추적을 위한 `skills` 로딩 이벤트 보장
- 상태 엔드포인트 경로를 `/{kickoff_id}/status`에서 `/status/{kickoff_id}`로 수정
- pt-BR 첫 흐름 가이드에서 누락된 코드 블록 복원
- `result_as_answer`가 후크 블록이나 오류 메시지를 최종 답변으로 반환하지 않도록 방지
- 비동기 배치 플러시 간 작업 출력 보존
- 항상 finally 블록에서 `task.output_pydantic` 복원
- `convert_to_model`에서 `BaseModel` 입력 처리
### 문서화
- v1.14.5에 대한 변경 로그 및 버전 업데이트
- OSS 업그레이드 및 Crew-투-흐름 마이그레이션 가이드 추가
- 개발 도구를 위한 추가 환경 변수 문서화
- `TavilyGetResearch`에 대한 문서 추가
### 리팩토링
- CLI를 독립형 `crewai-cli` 패키지로 추출
## 기여자
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
</Update>
<Update label="2026년 5월 18일">
## v1.14.5a7
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
## 변경 사항
### 문서
- v1.14.5a6의 변경 로그 및 버전 업데이트
### 주요 변경 사항
- function_calling_llm 필드 사용 중단
## 기여자
@greysonlalonde, @heitorado
</Update>
<Update label="2026년 5월 15일">
## v1.14.5a6
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
## 변경 사항
### 버그 수정
- available_functions가 없을 때 스트리밍 도구 호출 수정
- GHSA-3644-q5cj-c5c7 문제를 해결하기 위해 langsmith 의존성을 버전 >=0.8.0으로 업데이트
- 브라질 포르투갈어 문서에서 번역되지 않은 코드 블록 자리 표시자 해결
### 문서
- TavilyGetResearch에 대한 문서 추가
- v1.14.5a5에 대한 변경 로그 및 버전 업데이트
## 기여자
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
</Update>
<Update label="2026년 5월 13일">
## v1.14.5a5

View File

@@ -29,6 +29,7 @@ from crewai.flow.flow import Flow, listen, start
from dotenv import load_dotenv
from litellm import completion
load_dotenv()
class ExampleFlow(Flow):
model = "gpt-4o-mini"

View File

@@ -145,7 +145,6 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
</Step>
{" "}
<Step title="질문에 답하기">
crew assistant가 요구 사항을 구체화할 수 있도록 하는 추가 질문에 답변하세요.
</Step>
@@ -159,12 +158,10 @@ LLM 연결과 기본 설정을 구성했다면 이제 Crew Studio 사용을 시
</Step>
{" "}
<Step title="승인 또는 수정">
계획을 승인하거나 필요하다면 변경을 요청하세요.
</Step>
{" "}
<Step title="다운로드 또는 배포">
사용자화를 위해 코드를 다운로드하거나 플랫폼에 직접 배포하세요.
</Step>

View File

@@ -797,7 +797,6 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
여러 차원에서 우수한 성능을 제공하며 실제 환경에서 광범위하게 검증된 **GPT-4.1**, **Claude 3.7 Sonnet**, **Gemini 2.0 Flash**와 같은 잘 알려진 모델부터 시작하십시오.
</Step>
{" "}
<Step title="특화된 요구 사항 식별">
crew에 코드 작성, reasoning, 속도 등 특정 요구가 있는지 확인하고, 이러한
요구에 부합하는 **Claude 4 Sonnet**(개발용) 또는 **o3**(복잡한 분석용)과 같은
@@ -805,7 +804,6 @@ LLM 선택을 최적화하고자 하는 팀을 위해 **CrewAI AMP 플랫폼**
더불어 **Groq**와 같은 빠른 추론 제공자를 고려할 수 있습니다.
</Step>
{" "}
<Step title="다중 모델 전략 구현">
각 에이전트의 역할에 따라 다양한 모델을 사용하세요. 관리자와 복잡한 작업에는
고성능 모델을, 일상적 운영에는 효율적인 모델을 적용합니다.

View File

@@ -4,6 +4,86 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="19 mai 2026">
## v1.14.5
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5)
## O que Mudou
### Recursos
- Deprecar `CrewAgentExecutor`, definir agentes Crew como `AgentExecutor`
- Melhorar ferramentas do sandbox Daytona
- Adicionar parâmetro de início `restore_from_state_id`
- Adicionar destaques ao `ExaSearchTool`, renomeando de `EXASearchTool`
### Correções de Bugs
- Corrigir vazamento de memória em `git.py` usando `cached_property`
- Exibir chamadas de ferramentas transmitidas quando `available_functions` está ausente
- Garantir eventos de carregamento de `skills` para rastros
- Corrigir caminho do endpoint de status de `/{kickoff_id}/status` para `/status/{kickoff_id}`
- Restaurar bloco de código ausente no guia de primeiro fluxo em pt-BR
- Impedir que `result_as_answer` retorne mensagens de bloqueio de hook ou de erro como resposta final
- Preservar saídas de tarefas durante o descarregamento assíncrono em lote
- Sempre restaurar `task.output_pydantic` no bloco finally
- Lidar com entrada de `BaseModel` em `convert_to_model`
### Documentação
- Atualizar changelog e versão para v1.14.5
- Adicionar guia de migração de atualização OSS & crew-to-flow
- Documentar variáveis de ambiente adicionais para devtools
- Adicionar documentação para `TavilyGetResearch`
### Refatoração
- Extrair CLI para o pacote autônomo `crewai-cli`
## Contribuidores
@NIK-TIGER-BILL, @akaKuruma, @cgoeppinger, @github-actions[bot], @greysonlalonde, @heitorado, @irfaan101, @iris-clawd, @lorenzejay, @manisrinivasan2k1, @minasami-pr, @mislavivanda, @theCyberTech, @theishangoswami, @wishhyt
</Update>
<Update label="18 mai 2026">
## v1.14.5a7
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a7)
## O que Mudou
### Documentação
- Atualizar changelog e versão para v1.14.5a6
### Mudanças Quebradoras
- Depreciar o campo function_calling_llm
## Contributors
@greysonlalonde, @heitorado
</Update>
<Update label="15 mai 2026">
## v1.14.5a6
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.5a6)
## O que mudou
### Correções de Bugs
- Corrigir chamadas de ferramentas transmitidas quando available_functions está ausente
- Atualizar a dependência langsmith para a versão >=0.8.0 para resolver GHSA-3644-q5cj-c5c7
- Resolver espaços reservados de blocos de código não traduzidos na documentação em português brasileiro
### Documentação
- Adicionar documentação para TavilyGetResearch
- Atualizar changelog e versão para v1.14.5a5
## Contributors
@greysonlalonde, @heitorado, @iris-clawd, @lorenzejay, @manisrinivasan2k1
</Update>
<Update label="13 mai 2026">
## v1.14.5a5

View File

@@ -24,7 +24,63 @@ Os flows permitem que você crie fluxos de trabalho estruturados e orientados po
Vamos criar um Flow simples no qual você usará a OpenAI para gerar uma cidade aleatória em uma tarefa e, em seguida, usará essa cidade para gerar uma curiosidade em outra tarefa.
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, start
from dotenv import load_dotenv
from litellm import completion
load_dotenv()
class ExampleFlow(Flow):
model = "gpt-4o-mini"
@start()
def generate_city(self):
print("Starting flow")
# Cada estado do flow recebe automaticamente um ID único
print(f"Flow State ID: {self.state['id']}")
response = completion(
model=self.model,
messages=[
{
"role": "user",
"content": "Return the name of a random city in the world.",
},
],
)
random_city = response["choices"][0]["message"]["content"]
# Armazena a cidade no nosso estado
self.state["city"] = random_city
print(f"Random City: {random_city}")
return random_city
@listen(generate_city)
def generate_fun_fact(self, random_city):
response = completion(
model=self.model,
messages=[
{
"role": "user",
"content": f"Tell me a fun fact about {random_city}",
},
],
)
fun_fact = response["choices"][0]["message"]["content"]
# Armazena a curiosidade no nosso estado
self.state["fun_fact"] = fun_fact
return fun_fact
flow = ExampleFlow()
flow.plot()
result = flow.kickoff()
print(f"Generated fun fact: {result}")
```
Na ilustração acima, criamos um Flow simples que gera uma cidade aleatória usando a OpenAI e depois cria uma curiosidade sobre essa cidade. O Flow consiste em duas tarefas: `generate_city` e `generate_fun_fact`. A tarefa `generate_city` é o ponto de início do Flow, enquanto a tarefa `generate_fun_fact` fica escutando o resultado da tarefa `generate_city`.
@@ -56,12 +112,16 @@ O decorador `@listen()` pode ser usado de várias formas:
1. **Escutando um Método pelo Nome**: Você pode passar o nome do método ao qual deseja escutar como string. Quando esse método concluir, o método ouvinte será chamado.
```python Code
# (O código não é traduzido)
@listen("generate_city")
def generate_fun_fact(self, random_city):
# Implementação
```
2. **Escutando um Método Diretamente**: Você pode passar o próprio método. Quando esse método concluir, o método ouvinte será chamado.
```python Code
# (O código não é traduzido)
@listen(generate_city)
def generate_fun_fact(self, random_city):
# Implementação
```
### Saída de um Flow
@@ -76,7 +136,24 @@ Veja como acessar a saída final:
<CodeGroup>
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, start
class OutputExampleFlow(Flow):
@start()
def first_method(self):
return "Output from first_method"
@listen(first_method)
def second_method(self, first_output):
return f"Second method received: {first_output}"
flow = OutputExampleFlow()
flow.plot("my_flow_plot")
final_output = flow.kickoff()
print("---- Final Output ----")
print(final_output)
```
```text Output
@@ -97,8 +174,34 @@ Além de recuperar a saída final, você pode acessar e atualizar o estado dentr
Veja um exemplo de como atualizar e acessar o estado:
<CodeGroup>
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel
class ExampleState(BaseModel):
counter: int = 0
message: str = ""
class StateExampleFlow(Flow[ExampleState]):
@start()
def first_method(self):
self.state.message = "Hello from first_method"
self.state.counter += 1
@listen(first_method)
def second_method(self):
self.state.message += " - updated by second_method"
self.state.counter += 1
return self.state.message
flow = StateExampleFlow()
flow.plot("my_flow_plot")
final_output = flow.kickoff()
print(f"Final Output: {final_output}")
print("Final State:")
print(flow.state)
```
```text Output
@@ -128,7 +231,33 @@ Essa abordagem oferece flexibilidade, permitindo que o desenvolvedor adicione ou
Mesmo com estados não estruturados, os flows do CrewAI geram e mantêm automaticamente um identificador único (UUID) para cada instância de estado.
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, start
class UnstructuredExampleFlow(Flow):
@start()
def first_method(self):
# O estado inclui automaticamente um campo 'id'
print(f"State ID: {self.state['id']}")
self.state['counter'] = 0
self.state['message'] = "Hello from structured flow"
@listen(first_method)
def second_method(self):
self.state['counter'] += 1
self.state['message'] += " - updated"
@listen(second_method)
def third_method(self):
self.state['counter'] += 1
self.state['message'] += " - updated again"
print(f"State after third_method: {self.state}")
flow = UnstructuredExampleFlow()
flow.plot("my_flow_plot")
flow.kickoff()
```
![Flow Visual image](/images/crewai-flow-3.png)
@@ -148,7 +277,39 @@ Ao usar modelos como o `BaseModel` da Pydantic, os desenvolvedores podem definir
Cada estado nos flows do CrewAI recebe automaticamente um identificador único (UUID) para ajudar no rastreamento e gerenciamento. Esse ID é gerado e mantido automaticamente pelo sistema de flows.
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel
class ExampleState(BaseModel):
# Nota: o campo 'id' é adicionado automaticamente a todos os estados
counter: int = 0
message: str = ""
class StructuredExampleFlow(Flow[ExampleState]):
@start()
def first_method(self):
# Acesse o ID gerado automaticamente, se necessário
print(f"State ID: {self.state.id}")
self.state.message = "Hello from structured flow"
@listen(first_method)
def second_method(self):
self.state.counter += 1
self.state.message += " - updated"
@listen(second_method)
def third_method(self):
self.state.counter += 1
self.state.message += " - updated again"
print(f"State after third_method: {self.state}")
flow = StructuredExampleFlow()
flow.kickoff()
```
![Flow Visual image](/images/crewai-flow-3.png)
@@ -182,7 +343,19 @@ O decorador @persist permite a persistência automática do estado nos flows do
Quando aplicado no nível da classe, o decorador @persist garante a persistência automática de todos os estados dos métodos do flow:
```python
# (O código não é traduzido)
@persist # Usa SQLiteFlowPersistence por padrão
class MyFlow(Flow[MyState]):
@start()
def initialize_flow(self):
# Este método terá seu estado persistido automaticamente
self.state.counter = 1
print("Initialized flow. State ID:", self.state.id)
@listen(initialize_flow)
def next_step(self):
# O estado (incluindo self.state.id) é recarregado automaticamente
self.state.counter += 1
print("Flow state is persisted. Counter:", self.state.counter)
```
### Persistência no Nível de Método
@@ -190,7 +363,14 @@ Quando aplicado no nível da classe, o decorador @persist garante a persistênci
Para um controle mais granular, você pode aplicar @persist em métodos específicos:
```python
# (O código não é traduzido)
class AnotherFlow(Flow[dict]):
@persist # Persiste apenas o estado deste método
@start()
def begin(self):
if "runs" not in self.state:
self.state["runs"] = 0
self.state["runs"] += 1
print("Method-level persisted runs:", self.state["runs"])
```
### Forking de Estado Persistido
@@ -282,8 +462,29 @@ A arquitetura de persistência enfatiza precisão técnica e opções de persona
A função `or_` nos flows permite escutar múltiplos métodos e acionar o método ouvinte quando qualquer um dos métodos especificados gerar uma saída.
<CodeGroup>
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, listen, or_, start
class OrExampleFlow(Flow):
@start()
def start_method(self):
return "Hello from the start method"
@listen(start_method)
def second_method(self):
return "Hello from the second method"
@listen(or_(start_method, second_method))
def logger(self, result):
print(f"Logger: {result}")
flow = OrExampleFlow()
flow.plot("my_flow_plot")
flow.kickoff()
```
```text Output
@@ -302,8 +503,28 @@ A função `or_` serve para escutar vários métodos e disparar o método ouvint
A função `and_` nos flows permite escutar múltiplos métodos e acionar o método ouvinte apenas quando todos os métodos especificados emitirem uma saída.
<CodeGroup>
```python Code
# (O código não é traduzido)
from crewai.flow.flow import Flow, and_, listen, start
class AndExampleFlow(Flow):
@start()
def start_method(self):
self.state["greeting"] = "Hello from the start method"
@listen(start_method)
def second_method(self):
self.state["joke"] = "What do computers eat? Microchips."
@listen(and_(start_method, second_method))
def logger(self):
print("---- Logger ----")
print(self.state)
flow = AndExampleFlow()
flow.plot()
flow.kickoff()
```
```text Output
@@ -323,8 +544,42 @@ O decorador `@router()` nos flows permite definir lógica de roteamento condicio
Você pode especificar diferentes rotas conforme a saída do método, permitindo controlar o fluxo de execução de forma dinâmica.
<CodeGroup>
```python Code
# (O código não é traduzido)
import random
from crewai.flow.flow import Flow, listen, router, start
from pydantic import BaseModel
class ExampleState(BaseModel):
success_flag: bool = False
class RouterFlow(Flow[ExampleState]):
@start()
def start_method(self):
print("Starting the structured flow")
random_boolean = random.choice([True, False])
self.state.success_flag = random_boolean
@router(start_method)
def second_method(self):
if self.state.success_flag:
return "success"
else:
return "failed"
@listen("success")
def third_method(self):
print("Third method running")
@listen("failed")
def fourth_method(self):
print("Fourth method running")
flow = RouterFlow()
flow.plot("my_flow_plot")
flow.kickoff()
```
```text Output
@@ -401,7 +656,105 @@ Para um guia completo sobre feedback humano em flows, incluindo feedback assínc
Os agentes podem ser integrados facilmente aos seus flows, oferecendo uma alternativa leve às crews completas quando você precisar executar tarefas simples e focadas. Veja um exemplo de como utilizar um agente em um flow para realizar uma pesquisa de mercado:
```python
# (O código não é traduzido)
import asyncio
from typing import Any, Dict, List
from crewai_tools import SerperDevTool
from pydantic import BaseModel, Field
from crewai.agent import Agent
from crewai.flow.flow import Flow, listen, start
# Define um formato de saída estruturado
class MarketAnalysis(BaseModel):
key_trends: List[str] = Field(description="List of identified market trends")
market_size: str = Field(description="Estimated market size")
competitors: List[str] = Field(description="Major competitors in the space")
# Define o estado do flow
class MarketResearchState(BaseModel):
product: str = ""
analysis: MarketAnalysis | None = None
# Cria uma classe de flow
class MarketResearchFlow(Flow[MarketResearchState]):
@start()
def initialize_research(self) -> Dict[str, Any]:
print(f"Starting market research for {self.state.product}")
return {"product": self.state.product}
@listen(initialize_research)
async def analyze_market(self) -> Dict[str, Any]:
# Cria um agente para pesquisa de mercado
analyst = Agent(
role="Market Research Analyst",
goal=f"Analyze the market for {self.state.product}",
backstory="You are an experienced market analyst with expertise in "
"identifying market trends and opportunities.",
tools=[SerperDevTool()],
verbose=True,
)
# Define a consulta de pesquisa
query = f"""
Research the market for {self.state.product}. Include:
1. Key market trends
2. Market size
3. Major competitors
Format your response according to the specified structure.
"""
# Executa a análise com formato de saída estruturado
result = await analyst.kickoff_async(query, response_format=MarketAnalysis)
if result.pydantic:
print("result", result.pydantic)
else:
print("result", result)
# Retorna a análise para atualizar o estado
return {"analysis": result.pydantic}
@listen(analyze_market)
def present_results(self, analysis) -> None:
print("\nMarket Analysis Results")
print("=====================")
if isinstance(analysis, dict):
# Se recebemos um dict com a chave 'analysis', extrai o objeto de análise real
market_analysis = analysis.get("analysis")
else:
market_analysis = analysis
if market_analysis and isinstance(market_analysis, MarketAnalysis):
print("\nKey Market Trends:")
for trend in market_analysis.key_trends:
print(f"- {trend}")
print(f"\nMarket Size: {market_analysis.market_size}")
print("\nMajor Competitors:")
for competitor in market_analysis.competitors:
print(f"- {competitor}")
else:
print("No structured analysis data available.")
print("Raw analysis:", analysis)
# Exemplo de uso
async def run_flow():
flow = MarketResearchFlow()
flow.plot("MarketResearchFlowPlot")
result = await flow.kickoff_async(inputs={"product": "AI-powered chatbots"})
return result
# Executa o flow
if __name__ == "__main__":
asyncio.run(run_flow())
```
![Flow Visual image](/images/crewai-flow-7.png)
@@ -463,7 +816,50 @@ No arquivo `main.py`, você cria seu flow e conecta as crews. É possível defin
Veja um exemplo de como conectar a `poem_crew` no arquivo `main.py`:
```python Code
# (O código não é traduzido)
#!/usr/bin/env python
from random import randint
from pydantic import BaseModel
from crewai.flow.flow import Flow, listen, start
from .crews.poem_crew.poem_crew import PoemCrew
class PoemState(BaseModel):
sentence_count: int = 1
poem: str = ""
class PoemFlow(Flow[PoemState]):
@start()
def generate_sentence_count(self):
print("Generating sentence count")
self.state.sentence_count = randint(1, 5)
@listen(generate_sentence_count)
def generate_poem(self):
print("Generating poem")
result = PoemCrew().crew().kickoff(inputs={"sentence_count": self.state.sentence_count})
print("Poem generated", result.raw)
self.state.poem = result.raw
@listen(generate_poem)
def save_poem(self):
print("Saving poem")
with open("poem.txt", "w") as f:
f.write(self.state.poem)
def kickoff():
poem_flow = PoemFlow()
poem_flow.kickoff()
def plot():
poem_flow = PoemFlow()
poem_flow.plot("PoemFlowPlot")
if __name__ == "__main__":
kickoff()
plot()
```
Neste exemplo, a classe `PoemFlow` define um fluxo que gera a quantidade de frases, usa a `PoemCrew` para gerar um poema e, depois, salva o poema em um arquivo. O flow inicia com o método `kickoff()`, e o gráfico é gerado pelo método `plot()`.
@@ -515,7 +911,8 @@ O CrewAI oferece duas formas práticas de gerar plots dos seus flows:
Se estiver trabalhando diretamente com uma instância do flow, basta chamar o método `plot()` do objeto. Isso criará um arquivo HTML com o plot interativo do seu flow.
```python Code
# (O código não é traduzido)
# Considerando que você já tem uma instância do flow
flow.plot("my_flow_plot")
```
Esse comando gera um arquivo chamado `my_flow_plot.html` no diretório atual. Abra esse arquivo em um navegador para visualizar o plot interativo.

View File

@@ -146,7 +146,6 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
</Step>
{" "}
<Step title="Responder Perguntas">
Responda às perguntas de esclarecimento do Crew Assistant para refinar seus
requisitos.
@@ -161,12 +160,10 @@ Veja um fluxo de trabalho típico para criação de um crew com o Crew Studio:
</Step>
{" "}
<Step title="Aprovar ou Modificar">
Aprove o plano ou solicite alterações, se necessário.
</Step>
{" "}
<Step title="Baixar ou Fazer Deploy">
Baixe o código para personalização ou faça o deploy diretamente na plataforma.
</Step>

View File

@@ -63,7 +63,60 @@ Com estado não estruturado:
Veja um exemplo simples de gerenciamento de estado não estruturado:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
class UnstructuredStateFlow(Flow):
@start()
def initialize_data(self):
print("Initializing flow data")
# Adiciona pares chave-valor ao estado
self.state["user_name"] = "Alex"
self.state["preferences"] = {
"theme": "dark",
"language": "English"
}
self.state["items"] = []
# O estado do flow recebe automaticamente um ID único
print(f"Flow ID: {self.state['id']}")
return "Initialized"
@listen(initialize_data)
def process_data(self, previous_result):
print(f"Previous step returned: {previous_result}")
# Acessa e modifica o estado
user = self.state["user_name"]
print(f"Processing data for {user}")
# Adiciona itens a uma lista no estado
self.state["items"].append("item1")
self.state["items"].append("item2")
# Adiciona um novo par chave-valor
self.state["processed"] = True
return "Processed"
@listen(process_data)
def generate_summary(self, previous_result):
# Acessa múltiplos valores do estado
user = self.state["user_name"]
theme = self.state["preferences"]["theme"]
items = self.state["items"]
processed = self.state.get("processed", False)
summary = f"User {user} has {len(items)} items with {theme} theme. "
summary += "Data is processed." if processed else "Data is not processed."
return summary
# Executa o flow
flow = UnstructuredStateFlow()
result = flow.kickoff()
print(f"Final result: {result}")
print(f"Final state: {flow.state}")
```
### Quando Usar Estado Não Estruturado
@@ -94,7 +147,63 @@ Ao utilizar estado estruturado:
Veja como implementar o gerenciamento de estado estruturado:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel, Field
from typing import List, Dict, Optional
# Define o modelo de estado
class UserPreferences(BaseModel):
theme: str = "light"
language: str = "English"
class AppState(BaseModel):
user_name: str = ""
preferences: UserPreferences = UserPreferences()
items: List[str] = []
processed: bool = False
completion_percentage: float = 0.0
# Cria um flow com estado tipado
class StructuredStateFlow(Flow[AppState]):
@start()
def initialize_data(self):
print("Initializing flow data")
# Define valores do estado (com checagem de tipo)
self.state.user_name = "Taylor"
self.state.preferences.theme = "dark"
# O campo ID está disponível automaticamente
print(f"Flow ID: {self.state.id}")
return "Initialized"
@listen(initialize_data)
def process_data(self, previous_result):
print(f"Processing data for {self.state.user_name}")
# Modifica o estado (com checagem de tipo)
self.state.items.append("item1")
self.state.items.append("item2")
self.state.processed = True
self.state.completion_percentage = 50.0
return "Processed"
@listen(process_data)
def generate_summary(self, previous_result):
# Acessa o estado (com autocompletar)
summary = f"User {self.state.user_name} has {len(self.state.items)} items "
summary += f"with {self.state.preferences.theme} theme. "
summary += "Data is processed." if self.state.processed else "Data is not processed."
summary += f" Completion: {self.state.completion_percentage}%"
return summary
# Executa o flow
flow = StructuredStateFlow()
result = flow.kickoff()
print(f"Final result: {result}")
print(f"Final state: {flow.state}")
```
### Benefícios do Estado Estruturado
@@ -138,7 +247,29 @@ Independente de você usar estado estruturado ou não estruturado, é possível
Métodos do flow podem retornar valores que serão passados como argumento para métodos listeners:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
class DataPassingFlow(Flow):
@start()
def generate_data(self):
# Este valor de retorno será passado para os métodos listeners
return "Generated data"
@listen(generate_data)
def process_data(self, data_from_previous_step):
print(f"Received: {data_from_previous_step}")
# Você pode modificar os dados e repassá-los adiante
processed_data = f"{data_from_previous_step} - processed"
# Também atualiza o estado
self.state["last_processed"] = processed_data
return processed_data
@listen(process_data)
def finalize_data(self, processed_data):
print(f"Received processed data: {processed_data}")
# Acessa tanto os dados passados quanto o estado
last_processed = self.state.get("last_processed", "")
return f"Final: {processed_data} (from state: {last_processed})"
```
Esse padrão permite combinar passagem de dados direta com atualizações de estado para obter máxima flexibilidade.
@@ -156,7 +287,36 @@ O decorador `@persist()` automatiza a persistência de estado, salvando o estado
Ao aplicar em nível de classe, `@persist()` salva o estado após cada execução de método:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
from pydantic import BaseModel
class CounterState(BaseModel):
value: int = 0
@persist() # Aplica à classe inteira do flow
class PersistentCounterFlow(Flow[CounterState]):
@start()
def increment(self):
self.state.value += 1
print(f"Incremented to {self.state.value}")
return self.state.value
@listen(increment)
def double(self, value):
self.state.value = value * 2
print(f"Doubled to {self.state.value}")
return self.state.value
# Primeira execução
flow1 = PersistentCounterFlow()
result1 = flow1.kickoff()
print(f"First run result: {result1}")
# Segunda execução - passa o ID para carregar o estado persistido
flow2 = PersistentCounterFlow()
result2 = flow2.kickoff(inputs={"id": flow1.state.id})
print(f"Second run result: {result2}") # Será maior devido ao estado persistido
```
#### Persistência em Nível de Método
@@ -164,7 +324,26 @@ Ao aplicar em nível de classe, `@persist()` salva o estado após cada execuçã
Para mais controle, você pode aplicar `@persist()` em métodos específicos:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence import persist
class SelectivePersistFlow(Flow):
@start()
def first_step(self):
self.state["count"] = 1
return "First step"
@persist() # Persiste apenas após este método
@listen(first_step)
def important_step(self, prev_result):
self.state["count"] += 1
self.state["important_data"] = "This will be persisted"
return "Important step completed"
@listen(important_step)
def final_step(self, prev_result):
self.state["count"] += 1
return f"Complete with count {self.state['count']}"
```
#### Forking de Estado Persistido
@@ -216,7 +395,45 @@ Notas sobre o comportamento:
Você pode usar o estado para implementar lógicas condicionais complexas em seus flows:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, router, start
from pydantic import BaseModel
class PaymentState(BaseModel):
amount: float = 0.0
is_approved: bool = False
retry_count: int = 0
class PaymentFlow(Flow[PaymentState]):
@start()
def process_payment(self):
# Simula o processamento do pagamento
self.state.amount = 100.0
self.state.is_approved = self.state.amount < 1000
return "Payment processed"
@router(process_payment)
def check_approval(self, previous_result):
if self.state.is_approved:
return "approved"
elif self.state.retry_count < 3:
return "retry"
else:
return "rejected"
@listen("approved")
def handle_approval(self):
return f"Payment of ${self.state.amount} approved!"
@listen("retry")
def handle_retry(self):
self.state.retry_count += 1
print(f"Retrying payment (attempt {self.state.retry_count})...")
# Aqui poderia ser implementada a lógica de retry
return "Retry initiated"
@listen("rejected")
def handle_rejection(self):
return f"Payment of ${self.state.amount} rejected after {self.state.retry_count} retries."
```
### Manipulações Complexas de Estado
@@ -224,7 +441,60 @@ Você pode usar o estado para implementar lógicas condicionais complexas em seu
Para transformar estados complexos, você pode criar métodos dedicados:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel
from typing import List, Dict
class UserData(BaseModel):
name: str
active: bool = True
login_count: int = 0
class ComplexState(BaseModel):
users: Dict[str, UserData] = {}
active_user_count: int = 0
class TransformationFlow(Flow[ComplexState]):
@start()
def initialize(self):
# Adiciona alguns usuários
self.add_user("alice", "Alice")
self.add_user("bob", "Bob")
self.add_user("charlie", "Charlie")
return "Initialized"
@listen(initialize)
def process_users(self, _):
# Incrementa contagens de login
for user_id in self.state.users:
self.increment_login(user_id)
# Desativa um usuário
self.deactivate_user("bob")
# Atualiza a contagem de ativos
self.update_active_count()
return f"Processed {len(self.state.users)} users"
# Métodos auxiliares para transformações de estado
def add_user(self, user_id: str, name: str):
self.state.users[user_id] = UserData(name=name)
self.update_active_count()
def increment_login(self, user_id: str):
if user_id in self.state.users:
self.state.users[user_id].login_count += 1
def deactivate_user(self, user_id: str):
if user_id in self.state.users:
self.state.users[user_id].active = False
self.update_active_count()
def update_active_count(self):
self.state.active_user_count = sum(
1 for user in self.state.users.values() if user.active
)
```
Esse padrão de criar métodos auxiliares mantém seus métodos de flow limpos, enquanto permite manipulações complexas de estado.
@@ -238,7 +508,71 @@ Um dos padrões mais poderosos na CrewAI é combinar o gerenciamento de estado d
Você pode usar o estado do flow para parametrizar crews:
```python
# código não traduzido
from crewai.flow.flow import Flow, listen, start
from crewai import Agent, Crew, Process, Task
from pydantic import BaseModel
class ResearchState(BaseModel):
topic: str = ""
depth: str = "medium"
results: str = ""
class ResearchFlow(Flow[ResearchState]):
@start()
def get_parameters(self):
# Em uma aplicação real, isso pode vir da entrada do usuário
self.state.topic = "Artificial Intelligence Ethics"
self.state.depth = "deep"
return "Parameters set"
@listen(get_parameters)
def execute_research(self, _):
# Cria os agentes
researcher = Agent(
role="Research Specialist",
goal=f"Research {self.state.topic} in {self.state.depth} detail",
backstory="You are an expert researcher with a talent for finding accurate information."
)
writer = Agent(
role="Content Writer",
goal="Transform research into clear, engaging content",
backstory="You excel at communicating complex ideas clearly and concisely."
)
# Cria as tarefas
research_task = Task(
description=f"Research {self.state.topic} with {self.state.depth} analysis",
expected_output="Comprehensive research notes in markdown format",
agent=researcher
)
writing_task = Task(
description=f"Create a summary on {self.state.topic} based on the research",
expected_output="Well-written article in markdown format",
agent=writer,
context=[research_task]
)
# Cria e executa a crew
research_crew = Crew(
agents=[researcher, writer],
tasks=[research_task, writing_task],
process=Process.sequential,
verbose=True
)
# Executa a crew e armazena o resultado no estado
result = research_crew.kickoff()
self.state.results = result.raw
return "Research completed"
@listen(execute_research)
def summarize_results(self, _):
# Acessa os resultados armazenados
result_length = len(self.state.results)
return f"Research on {self.state.topic} completed with {result_length} characters of results."
```
### Manipulando Saídas de Crews no Estado
@@ -246,7 +580,21 @@ Você pode usar o estado do flow para parametrizar crews:
Quando um crew finaliza, é possível processar sua saída e armazená-la no estado do flow:
```python
# código não traduzido
@listen(execute_crew)
def process_crew_results(self, _):
# Faz parsing dos resultados brutos (assumindo saída em JSON)
import json
try:
results_dict = json.loads(self.state.raw_results)
self.state.processed_results = {
"title": results_dict.get("title", ""),
"main_points": results_dict.get("main_points", []),
"conclusion": results_dict.get("conclusion", "")
}
return "Results processed successfully"
except json.JSONDecodeError:
self.state.error = "Failed to parse crew results as JSON"
return "Error processing results"
```
## Boas Práticas para Gerenciamento de Estado
@@ -256,7 +604,19 @@ Quando um crew finaliza, é possível processar sua saída e armazená-la no est
Projete seu estado para conter somente o necessário:
```python
# Exemplo não traduzido
# Abrangente demais
class BloatedState(BaseModel):
user_data: Dict = {}
system_settings: Dict = {}
temporary_calculations: List = []
debug_info: Dict = {}
# ...muitos outros campos
# Melhor: estado focado
class FocusedState(BaseModel):
user_id: str
preferences: Dict[str, str]
completion_status: Dict[str, bool]
```
### 2. Use Estado Estruturado em Flows Complexos
@@ -264,7 +624,23 @@ Projete seu estado para conter somente o necessário:
À medida que seus flows evoluem em complexidade, o estado estruturado se torna cada vez mais valioso:
```python
# Exemplo não traduzido
# Flow simples pode usar estado não estruturado
class SimpleGreetingFlow(Flow):
@start()
def greet(self):
self.state["name"] = "World"
return f"Hello, {self.state['name']}!"
# Flow complexo se beneficia de estado estruturado
class UserRegistrationState(BaseModel):
username: str
email: str
verification_status: bool = False
registration_date: datetime = Field(default_factory=datetime.now)
last_login: Optional[datetime] = None
class RegistrationFlow(Flow[UserRegistrationState]):
# Métodos com acesso ao estado fortemente tipado
```
### 3. Documente Transições de Estado
@@ -272,7 +648,18 @@ Projete seu estado para conter somente o necessário:
Para flows complexos, documente como o estado muda ao longo da execução:
```python
# Exemplo não traduzido
@start()
def initialize_order(self):
"""
Initialize order state with empty values.
State before: {}
State after: {order_id: str, items: [], status: 'new'}
"""
self.state.order_id = str(uuid.uuid4())
self.state.items = []
self.state.status = "new"
return "Order initialized"
```
### 4. Trate Erros de Estado de Forma Elegante
@@ -280,7 +667,18 @@ Para flows complexos, documente como o estado muda ao longo da execução:
Implemente tratamento de erros ao acessar o estado:
```python
# Exemplo não traduzido
@listen(previous_step)
def process_data(self, _):
try:
# Tenta acessar um valor que pode não existir
user_preference = self.state.preferences.get("theme", "default")
except (AttributeError, KeyError):
# Trata o erro de forma elegante
self.state.errors = self.state.get("errors", [])
self.state.errors.append("Failed to access preferences")
user_preference = "default"
return f"Used preference: {user_preference}"
```
### 5. Use o Estado Para Acompanhar o Progresso
@@ -288,7 +686,30 @@ Implemente tratamento de erros ao acessar o estado:
Aproveite o estado para monitorar o progresso em flows de longa duração:
```python
# Exemplo não traduzido
class ProgressTrackingFlow(Flow):
@start()
def initialize(self):
self.state["total_steps"] = 3
self.state["current_step"] = 0
self.state["progress"] = 0.0
self.update_progress()
return "Initialized"
def update_progress(self):
"""Helper method to calculate and update progress"""
if self.state.get("total_steps", 0) > 0:
self.state["progress"] = (self.state.get("current_step", 0) /
self.state["total_steps"]) * 100
print(f"Progress: {self.state['progress']:.1f}%")
@listen(initialize)
def step_one(self, _):
# Realiza o trabalho...
self.state["current_step"] = 1
self.update_progress()
return "Step 1 complete"
# Etapas adicionais...
```
### 6. Prefira Operações Imutáveis Quando Possível
@@ -296,7 +717,22 @@ Aproveite o estado para monitorar o progresso em flows de longa duração:
Especialmente com estado estruturado, prefira operações imutáveis para maior clareza:
```python
# Exemplo não traduzido
# Em vez de modificar listas no local:
self.state.items.append(new_item) # Operação mutável
# Considere criar um novo estado:
from pydantic import BaseModel
from typing import List
class ItemState(BaseModel):
items: List[str] = []
class ImmutableFlow(Flow[ItemState]):
@start()
def add_item(self):
# Cria uma nova lista com o item adicionado
self.state.items = [*self.state.items, "new item"]
return "Item added"
```
## Depurando o Estado do Flow
@@ -306,7 +742,24 @@ Especialmente com estado estruturado, prefira operações imutáveis para maior
Ao desenvolver, adicione logs para acompanhar mudanças no estado:
```python
# Exemplo não traduzido
import logging
logging.basicConfig(level=logging.INFO)
class LoggingFlow(Flow):
def log_state(self, step_name):
logging.info(f"State after {step_name}: {self.state}")
@start()
def initialize(self):
self.state["counter"] = 0
self.log_state("initialize")
return "Initialized"
@listen(initialize)
def increment(self, _):
self.state["counter"] += 1
self.log_state("increment")
return f"Incremented to {self.state['counter']}"
```
### Visualizando o Estado
@@ -314,7 +767,30 @@ Ao desenvolver, adicione logs para acompanhar mudanças no estado:
Você pode adicionar métodos para visualizar seu estado durante o debug:
```python
# Exemplo não traduzido
def visualize_state(self):
"""Create a simple visualization of the current state"""
import json
from rich.console import Console
from rich.panel import Panel
console = Console()
if hasattr(self.state, "model_dump"):
# Pydantic v2
state_dict = self.state.model_dump()
elif hasattr(self.state, "dict"):
# Pydantic v1
state_dict = self.state.dict()
else:
# Estado não estruturado
state_dict = dict(self.state)
# Remove o id para uma saída mais limpa
if "id" in state_dict:
state_dict.pop("id")
state_json = json.dumps(state_dict, indent=2, default=str)
console.print(Panel(state_json, title="Current Flow State"))
```
## Conclusão

View File

@@ -797,7 +797,6 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
Inicie com opções consagradas como **GPT-4.1**, **Claude 3.7 Sonnet** ou **Gemini 2.0 Flash**, que oferecem bom desempenho e ampla validação.
</Step>
{" "}
<Step title="Identifique Demandas Especializadas">
Descubra se sua crew possui requisitos específicos (código, raciocínio,
velocidade) que justifiquem modelos como **Claude 4 Sonnet** para
@@ -805,7 +804,6 @@ As tabelas abaixo mostram uma amostra dos modelos de maior destaque em cada cate
velocidade, considere Groq aliado à seleção do modelo.
</Step>
{" "}
<Step title="Implemente Estratégia Multi-Modelo">
Use modelos diferentes para agentes distintos conforme o papel. Modelos de
alta capacidade para managers e tarefas complexas, eficientes para rotinas.

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a5",
"crewai-core==1.14.5",
"click~=8.1.7",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",

View File

@@ -1 +1 @@
__version__ = "1.14.5a5"
__version__ = "1.14.5"

View File

@@ -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."""

View File

@@ -1,5 +1,6 @@
from typing import Any
from crewai_core.plus_api import CreateCrewPayload
from rich.console import Console
from crewai_cli import git
@@ -161,7 +162,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
self,
env_vars: dict[str, str],
remote_repo_url: str,
) -> dict[str, Any]:
) -> CreateCrewPayload:
"""
Create the payload for crew creation.
@@ -172,6 +173,8 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
Returns:
Dict[str, Any]: The payload for crew creation.
"""
if not self.project_name:
raise ValueError("project_name is required to create a deployment payload")
return {
"deploy": {
"name": self.project_name,

View File

@@ -1,4 +1,4 @@
from functools import lru_cache
from functools import cached_property
import subprocess
@@ -9,7 +9,7 @@ class Repository:
if not self.is_git_installed():
raise ValueError("Git is not installed or not found in your PATH.")
if not self.is_git_repo():
if not self.is_git_repo:
raise ValueError(f"{self.path} is not a Git repository.")
self.fetch()
@@ -40,13 +40,9 @@ class Repository:
encoding="utf-8",
).strip()
@lru_cache(maxsize=None) # noqa: B019
@cached_property
def is_git_repo(self) -> bool:
"""Check if the current directory is a git repository.
Notes:
- TODO: This method is cached to avoid redundant checks, but using lru_cache on methods can lead to memory leaks
"""
"""Check if the current directory is a git repository."""
try:
subprocess.check_output(
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607

View 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

View File

View 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

View File

@@ -1 +1 @@
__version__ = "1.14.5a5"
__version__ = "1.14.5"

View File

@@ -3,36 +3,162 @@
from __future__ import annotations
import os
from typing import Any
from typing import Any, Final, Literal, TypedDict, cast
from urllib.parse import urljoin
import httpx
from typing_extensions import NotRequired
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_core.settings import Settings
from crewai_core.version import get_crewai_version
HttpMethod = Literal["GET", "POST", "PATCH", "DELETE"]
class AvailableExport(TypedDict):
name: str
class EnvVarEntry(TypedDict):
name: str
description: str
required: bool
default: str | None
class ToolMetadata(TypedDict):
name: str
module: str
humanized_name: str
description: str
run_params_schema: dict[str, Any]
init_params_schema: dict[str, Any]
env_vars: list[EnvVarEntry]
class ToolsMetadataPayload(TypedDict):
package: str
tools: list[ToolMetadata] | None
class PublishToolPayload(TypedDict):
handle: str
public: bool
version: str
file: str
description: str | None
available_exports: list[AvailableExport] | None
tools_metadata: ToolsMetadataPayload | None
class CrewDeploymentSpec(TypedDict):
name: str
repo_clone_url: str
env: dict[str, str]
class CreateCrewPayload(TypedDict):
deploy: CrewDeploymentSpec
class _WithUserIdentifier(TypedDict):
user_identifier: NotRequired[str]
class LoginPayload(_WithUserIdentifier):
pass
class TraceExecutionContext(TypedDict):
crew_fingerprint: str | None
crew_name: str | None
flow_name: str | None
crewai_version: str
privacy_level: str
class TraceExecutionMetadata(TypedDict):
expected_duration_estimate: int
agent_count: int
task_count: int
flow_method_count: int
execution_started_at: str
class TraceBatchInitPayload(_WithUserIdentifier):
trace_id: str
execution_type: str
execution_context: TraceExecutionContext
execution_metadata: TraceExecutionMetadata
ephemeral_trace_id: NotRequired[str]
class TraceBatchMetadata(TypedDict):
events_count: int
batch_sequence: int
is_final_batch: bool
class TraceEventsPayload(TypedDict):
events: list[dict[str, Any]]
batch_metadata: TraceBatchMetadata
class TraceFinalizePayload(TypedDict):
status: Literal["completed"]
duration_ms: float | None
final_event_count: int
class TraceFailedPayload(TypedDict):
status: Literal["failed"]
failure_reason: str
Headers = TypedDict(
"Headers",
{
"Content-Type": str,
"User-Agent": str,
"X-Crewai-Version": str,
"Authorization": NotRequired[str],
"X-Crewai-Organization-Id": NotRequired[str],
},
)
class RequestKwargs(TypedDict):
headers: dict[str, str]
json: NotRequired[Any]
params: NotRequired[dict[str, str]]
timeout: NotRequired[float]
class PlusAPI:
"""Client for working with the CrewAI+ API."""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
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"
TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE: Final = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE: Final = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
version = get_crewai_version()
self.api_key = api_key
self.headers = {
self.headers: Headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
"User-Agent": f"CrewAI-CLI/{version}",
"X-Crewai-Version": version,
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
@@ -44,17 +170,30 @@ class PlusAPI:
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
self,
method: HttpMethod,
endpoint: str,
*,
json: Any = None,
params: dict[str, str] | None = None,
timeout: float | None = None,
verify: bool = True,
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
request_kwargs: RequestKwargs = {"headers": cast(dict[str, str], self.headers)}
if json is not None:
request_kwargs["json"] = json
if params is not None:
request_kwargs["params"] = params
if timeout is not None:
request_kwargs["timeout"] = timeout
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
return client.request(method, url, **request_kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
payload: LoginPayload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
@@ -65,7 +204,7 @@ class PlusAPI:
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
return await client.get(url, headers=cast(dict[str, str], self.headers))
def publish_tool(
self,
@@ -74,10 +213,10 @@ class PlusAPI:
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
available_exports: list[AvailableExport] | None = None,
tools_metadata: list[ToolMetadata] | None = None,
) -> httpx.Response:
params = {
params: PublishToolPayload = {
"handle": handle,
"public": is_public,
"version": version,
@@ -90,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"
@@ -129,13 +309,13 @@ class PlusAPI:
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
def create_crew(self, payload: CreateCrewPayload) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
def initialize_trace_batch(self, payload: TraceBatchInitPayload) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
@@ -144,7 +324,7 @@ class PlusAPI:
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
self, payload: TraceBatchInitPayload
) -> httpx.Response:
return self._make_request(
"POST",
@@ -153,7 +333,7 @@ class PlusAPI:
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
self, trace_batch_id: str, payload: TraceEventsPayload
) -> httpx.Response:
return self._make_request(
"POST",
@@ -163,7 +343,7 @@ class PlusAPI:
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
self, trace_batch_id: str, payload: TraceEventsPayload
) -> httpx.Response:
return self._make_request(
"POST",
@@ -173,7 +353,7 @@ class PlusAPI:
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
self, trace_batch_id: str, payload: TraceFinalizePayload
) -> httpx.Response:
return self._make_request(
"PATCH",
@@ -183,7 +363,7 @@ class PlusAPI:
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
self, trace_batch_id: str, payload: TraceFinalizePayload
) -> httpx.Response:
return self._make_request(
"PATCH",
@@ -195,20 +375,28 @@ class PlusAPI:
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
payload: TraceFailedPayload = {
"status": "failed",
"failure_reason": error_message,
}
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
json=payload,
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
payload: TraceFailedPayload = {
"status": "failed",
"failure_reason": error_message,
}
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
json=payload,
timeout=30,
)

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.5a5"
__version__ = "1.14.5"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.5a5",
"crewai==1.14.5",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.5a5"
__version__ = "1.14.5"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a5",
"crewai-cli==1.14.5a5",
"crewai-core==1.14.5",
"crewai-cli==1.14.5",
# 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.5a5",
"crewai-tools==1.14.5",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -105,7 +105,7 @@ a2a = [
"aiocache[redis,memcached]~=0.12.3",
]
file-processing = [
"crewai-files==1.14.5a5",
"crewai-files",
]
qdrant-edge = [
"qdrant-edge-py>=0.6.0",

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.5a5"
__version__ = "1.14.5"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -1,20 +1,23 @@
"""String templates for A2A (Agent-to-Agent) delegation prompts."""
"""String templates for A2A (Agent-to-Agent) protocol messaging and status."""
from string import Template
from typing import Final
AVAILABLE_AGENTS_TEMPLATE: Final[Template] = Template(
"\n<AVAILABLE_A2A_AGENTS>\n"
"You can delegate to remote agents using the delegate_to_* tools below. "
"Each tool's description lists the remote agent's capabilities — call the "
"tool whose capabilities best match the task. Pass the question or sub-task "
"to the remote agent via the tool's `message` argument; the tool returns "
"the remote agent's response, which you should incorporate into your final "
"answer. If the available agents are not a good fit, answer directly "
"without calling a delegation tool.\n\n"
" $available_a2a_agents"
"\n</AVAILABLE_A2A_AGENTS>\n"
"\n<AVAILABLE_A2A_AGENTS>\n $available_a2a_agents\n</AVAILABLE_A2A_AGENTS>\n"
)
PREVIOUS_A2A_CONVERSATION_TEMPLATE: Final[Template] = Template(
"\n<PREVIOUS_A2A_CONVERSATION>\n"
" $previous_a2a_conversation"
"\n</PREVIOUS_A2A_CONVERSATION>\n"
)
CONVERSATION_TURN_INFO_TEMPLATE: Final[Template] = Template(
"\n<CONVERSATION_PROGRESS>\n"
' turn="$turn_count"\n'
' max_turns="$max_turns"\n'
" $warning"
"\n</CONVERSATION_PROGRESS>\n"
)
UNAVAILABLE_AGENTS_NOTICE_TEMPLATE: Final[Template] = Template(
"\n<A2A_AGENTS_STATUS>\n"
@@ -24,3 +27,29 @@ UNAVAILABLE_AGENTS_NOTICE_TEMPLATE: Final[Template] = Template(
" $unavailable_agents"
"\n</A2A_AGENTS_STATUS>\n"
)
REMOTE_AGENT_COMPLETED_NOTICE: Final[str] = """
<REMOTE_AGENT_STATUS>
STATUS: COMPLETED
The remote agent has finished processing your request. Their response is in the conversation history above.
You MUST now:
1. Extract the answer from the conversation history
2. Set is_a2a=false
3. Return the answer as your final message
DO NOT send another request - the task is already done.
</REMOTE_AGENT_STATUS>
"""
REMOTE_AGENT_RESPONSE_NOTICE: Final[str] = """
<REMOTE_AGENT_STATUS>
STATUS: RESPONSE_RECEIVED
The remote agent has responded. Their response is in the conversation history above.
You MUST now:
1. Set is_a2a=false (the remote task is complete and cannot receive more messages)
2. Provide YOUR OWN response to the original task based on the information received
IMPORTANT: Your response should be addressed to the USER who gave you the original task.
Report what the remote agent told you in THIRD PERSON (e.g., "The remote agent said..." or "I learned that...").
Do NOT address the remote agent directly or use "you" to refer to them.
</REMOTE_AGENT_STATUS>
"""

View File

@@ -1,394 +0,0 @@
"""Tool-based A2A delegation.
Each remote A2A agent is exposed to the local LLM as a BaseTool. The local
agent's normal tool-call loop drives multi-turn delegation: each tool call is
one turn against the remote agent. Per-endpoint conversation state lives in
``A2ADelegationState`` and is shared across the tools built for a single task.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from a2a.types import Role, TaskState
from pydantic import BaseModel, Field, PrivateAttr
from crewai.a2a.config import A2AClientConfig, A2AConfig
from crewai.a2a.extensions.base import (
A2AExtension,
ConversationState,
ExtensionRegistry,
)
from crewai.a2a.task_helpers import TaskStateResult
from crewai.a2a.utils.delegation import aexecute_a2a_delegation, execute_a2a_delegation
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.a2a_events import A2AConversationCompletedEvent
from crewai.tools.base_tool import BaseTool
from crewai.utilities.string_utils import sanitize_tool_name
if TYPE_CHECKING:
from a2a.types import AgentCard, Message
from crewai.task import Task
_DELEGATE_PREFIX = "delegate_to_"
@dataclass
class _EndpointState:
"""Mutable per-endpoint conversation state across tool calls."""
conversation_history: list[Message] = field(default_factory=list)
context_id: str | None = None
task_id: str | None = None
reference_task_ids: list[str] = field(default_factory=list)
turn_count: int = 0
@dataclass
class A2ADelegationState:
"""State shared across all A2A delegation tools for a single task execution."""
agent: Any
task: Task
extension_registry: ExtensionRegistry | None = None
_per_endpoint: dict[str, _EndpointState] = field(default_factory=dict)
def _state_for(self, endpoint: str) -> _EndpointState:
return self._per_endpoint.setdefault(endpoint, _EndpointState())
def _initial_ids_from_task(self, state: _EndpointState) -> None:
if state.turn_count > 0:
return
task_config = self.task.config or {}
if state.context_id is None:
state.context_id = task_config.get("context_id")
if state.task_id is None:
state.task_id = task_config.get("task_id")
if not state.reference_task_ids:
state.reference_task_ids = list(task_config.get("reference_task_ids", []))
def delegate(
self,
config: A2AConfig | A2AClientConfig,
agent_card: AgentCard | None,
message: str,
) -> str:
"""Run one delegation turn against ``config.endpoint``.
Returns the remote agent's response text, suitable for handing back to
the local LLM as a tool result.
"""
return _run_delegation(self, config, agent_card, message, sync=True)
async def adelegate(
self,
config: A2AConfig | A2AClientConfig,
agent_card: AgentCard | None,
message: str,
) -> str:
"""Async variant of :meth:`delegate`."""
return await _run_delegation_async(self, config, agent_card, message)
class _A2ADelegationArgs(BaseModel):
"""Argument schema for A2A delegation tools."""
message: str = Field(
...,
description=(
"The question or task to send to the remote agent. Be specific and "
"self-contained: the remote agent does not see your other tools or "
"your prior reasoning."
),
)
class A2ADelegationTool(BaseTool):
"""BaseTool that delegates one turn of conversation to a remote A2A agent.
Each instance is bound to a specific A2A endpoint via ``_config``. Calling
``_run`` or ``_arun`` advances that endpoint's conversation by one turn and
returns the remote agent's response text.
"""
args_schema: type[BaseModel] = _A2ADelegationArgs
_config: A2AConfig | A2AClientConfig = PrivateAttr()
_agent_card: AgentCard | None = PrivateAttr(default=None)
_state: A2ADelegationState = PrivateAttr()
def _run(self, message: str) -> str:
return self._state.delegate(self._config, self._agent_card, message)
async def _arun(self, message: str) -> str:
return await self._state.adelegate(self._config, self._agent_card, message)
def build_a2a_tools(
a2a_agents: list[A2AConfig | A2AClientConfig],
agent_cards: dict[str, AgentCard],
state: A2ADelegationState,
) -> list[BaseTool]:
"""Build one ``A2ADelegationTool`` per available A2A agent.
Tool names collide-disambiguate with a numeric suffix; agents whose cards
failed to fetch are skipped.
"""
tools: list[BaseTool] = []
used_names: set[str] = set()
for config in a2a_agents:
card = agent_cards.get(config.endpoint)
if card is None:
continue
name = _build_tool_name(card.name or "remote_agent", used_names)
used_names.add(name)
tool = A2ADelegationTool(
name=name,
description=_build_tool_description(card),
max_usage_count=config.max_turns,
)
tool._config = config
tool._agent_card = card
tool._state = state
tools.append(tool)
return tools
def _build_tool_name(card_name: str, used: set[str]) -> str:
base = sanitize_tool_name(f"{_DELEGATE_PREFIX}{card_name}")
if base not in used:
return base
for i in range(2, 1000):
candidate = sanitize_tool_name(f"{base}_{i}")
if candidate not in used:
return candidate
raise ValueError(f"Could not generate unique tool name for {card_name!r}")
def _build_tool_description(card: AgentCard) -> str:
lines: list[str] = [f"Delegate a task to the remote A2A agent {card.name!r}."]
if card.description:
lines.append(card.description.strip())
if card.skills:
skill_names = ", ".join(s.name for s in card.skills if s.name)
if skill_names:
lines.append(f"Capabilities: {skill_names}.")
lines.append(
"Use this tool only when the question matches the agent's capabilities. "
"After receiving a response, prefer answering directly unless you need "
"another round-trip."
)
return "\n".join(lines)
def _run_delegation(
state: A2ADelegationState,
config: A2AConfig | A2AClientConfig,
agent_card: AgentCard | None,
message: str,
*,
sync: bool,
) -> str:
endpoint_state = state._state_for(config.endpoint)
state._initial_ids_from_task(endpoint_state)
extension_states = _extract_extension_states(state, endpoint_state)
metadata = _merged_metadata(state, endpoint_state, extension_states)
agent_branch, accepted_output_modes = _turn_context(config)
a2a_result = execute_a2a_delegation(
endpoint=config.endpoint,
auth=config.auth,
timeout=config.timeout,
task_description=message,
context_id=endpoint_state.context_id,
task_id=endpoint_state.task_id,
reference_task_ids=endpoint_state.reference_task_ids,
metadata=metadata or None,
extensions=(state.task.config or {}).get("extensions"),
conversation_history=endpoint_state.conversation_history,
agent_id=config.endpoint,
agent_role=Role.user,
agent_branch=agent_branch,
response_model=config.response_model,
turn_number=endpoint_state.turn_count + 1,
updates=config.updates,
transport=config.transport,
from_task=state.task,
from_agent=state.agent,
client_extensions=getattr(config, "extensions", None),
accepted_output_modes=accepted_output_modes,
input_files=state.task.input_files,
)
return _finalize_turn(
state, endpoint_state, config, agent_card, a2a_result, extension_states
)
async def _run_delegation_async(
state: A2ADelegationState,
config: A2AConfig | A2AClientConfig,
agent_card: AgentCard | None,
message: str,
) -> str:
endpoint_state = state._state_for(config.endpoint)
state._initial_ids_from_task(endpoint_state)
extension_states = _extract_extension_states(state, endpoint_state)
metadata = _merged_metadata(state, endpoint_state, extension_states)
agent_branch, accepted_output_modes = _turn_context(config)
a2a_result = await aexecute_a2a_delegation(
endpoint=config.endpoint,
auth=config.auth,
timeout=config.timeout,
task_description=message,
context_id=endpoint_state.context_id,
task_id=endpoint_state.task_id,
reference_task_ids=endpoint_state.reference_task_ids,
metadata=metadata or None,
extensions=(state.task.config or {}).get("extensions"),
conversation_history=endpoint_state.conversation_history,
agent_id=config.endpoint,
agent_role=Role.user,
agent_branch=agent_branch,
response_model=config.response_model,
turn_number=endpoint_state.turn_count + 1,
updates=config.updates,
transport=config.transport,
from_task=state.task,
from_agent=state.agent,
client_extensions=getattr(config, "extensions", None),
accepted_output_modes=accepted_output_modes,
input_files=state.task.input_files,
)
return _finalize_turn(
state, endpoint_state, config, agent_card, a2a_result, extension_states
)
def _extract_extension_states(
state: A2ADelegationState,
endpoint_state: _EndpointState,
) -> dict[type[A2AExtension], ConversationState]:
if state.extension_registry and endpoint_state.conversation_history:
return state.extension_registry.extract_all_states(
endpoint_state.conversation_history
)
return {}
def _merged_metadata(
state: A2ADelegationState,
endpoint_state: _EndpointState,
extension_states: dict[type[A2AExtension], ConversationState],
) -> dict[str, Any]:
task_config = state.task.config or {}
metadata: dict[str, Any] = dict(task_config.get("metadata") or {})
if state.extension_registry and extension_states:
metadata.update(state.extension_registry.prepare_all_metadata(extension_states))
return metadata
def _turn_context(
config: A2AConfig | A2AClientConfig,
) -> tuple[Any | None, list[str] | None]:
console_formatter = getattr(crewai_event_bus, "_console", None)
agent_branch = None
if console_formatter:
agent_branch = getattr(
console_formatter, "current_agent_branch", None
) or getattr(console_formatter, "current_task_branch", None)
accepted_output_modes = None
if isinstance(config, A2AClientConfig):
accepted_output_modes = config.accepted_output_modes
return agent_branch, accepted_output_modes
def _finalize_turn(
state: A2ADelegationState,
endpoint_state: _EndpointState,
config: A2AConfig | A2AClientConfig,
agent_card: AgentCard | None,
a2a_result: TaskStateResult,
extension_states: dict[type[A2AExtension], ConversationState],
) -> str:
endpoint_state.conversation_history = list(a2a_result.get("history", []))
if endpoint_state.conversation_history:
latest = endpoint_state.conversation_history[-1]
if latest.task_id is not None:
endpoint_state.task_id = latest.task_id
if latest.context_id is not None:
endpoint_state.context_id = latest.context_id
endpoint_state.turn_count += 1
status = a2a_result.get("status")
if status == TaskState.completed:
if (
endpoint_state.task_id is not None
and endpoint_state.task_id not in endpoint_state.reference_task_ids
):
endpoint_state.reference_task_ids.append(endpoint_state.task_id)
if state.task.config is None:
state.task.config = {}
state.task.config["reference_task_ids"] = list(
endpoint_state.reference_task_ids
)
endpoint_state.task_id = None
result_text = str(a2a_result.get("result", ""))
crewai_event_bus.emit(
None,
A2AConversationCompletedEvent(
status="completed",
final_result=result_text,
error=None,
total_turns=endpoint_state.turn_count,
from_task=state.task,
from_agent=state.agent,
endpoint=config.endpoint,
a2a_agent_name=agent_card.name if agent_card else None,
agent_card=agent_card.model_dump() if agent_card else None,
),
)
return _apply_response_extensions(state, result_text, extension_states)
if status == TaskState.input_required:
result_text = str(a2a_result.get("result", ""))
return _apply_response_extensions(state, result_text, extension_states)
error_msg = a2a_result.get("error", "Unknown error")
crewai_event_bus.emit(
None,
A2AConversationCompletedEvent(
status="failed",
final_result=None,
error=error_msg,
total_turns=endpoint_state.turn_count,
from_task=state.task,
from_agent=state.agent,
endpoint=config.endpoint,
a2a_agent_name=agent_card.name if agent_card else None,
agent_card=agent_card.model_dump() if agent_card else None,
),
)
return f"Remote agent error: {error_msg}"
def _apply_response_extensions(
state: A2ADelegationState,
response_text: str,
extension_states: dict[type[A2AExtension], ConversationState],
) -> str:
if not state.extension_registry:
return response_text
processed = state.extension_registry.process_response_with_all(
response_text, extension_states
)
return processed if isinstance(processed, str) else str(processed)

View File

@@ -6,6 +6,8 @@ from typing import (
Annotated,
Any,
Literal,
Protocol,
runtime_checkable,
)
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
@@ -55,6 +57,15 @@ Url = Annotated[
]
@runtime_checkable
class AgentResponseProtocol(Protocol):
"""Protocol for the dynamically created AgentResponse model."""
a2a_ids: tuple[str, ...]
message: str
is_a2a: bool
class PartsMetadataDict(TypedDict, total=False):
"""Metadata for A2A message parts.

View File

@@ -1,25 +1,75 @@
"""Helpers for extracting A2A client configurations."""
"""Response model utilities for A2A agent interactions."""
from __future__ import annotations
from typing import TypeAlias
from pydantic import BaseModel, Field, create_model
from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig
from crewai.types.utils import create_literals_from_strings
A2AConfigTypes: TypeAlias = A2AConfig | A2AServerConfig | A2AClientConfig
A2AClientConfigTypes: TypeAlias = A2AConfig | A2AClientConfig
def extract_a2a_client_configs(
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
) -> list[A2AClientConfigTypes]:
"""Return the client-side A2A configs from a possibly-mixed config list.
def create_agent_response_model(agent_ids: tuple[str, ...]) -> type[BaseModel] | None:
"""Create a dynamic AgentResponse model with Literal types for agent IDs.
Filters out :class:`A2AServerConfig`, which has no endpoint to delegate to.
Args:
agent_ids: List of available A2A agent IDs.
Returns:
Dynamically created Pydantic model with Literal-constrained a2a_ids field,
or None if agent_ids is empty.
"""
if not agent_ids:
return None
DynamicLiteral = create_literals_from_strings(agent_ids) # noqa: N806
return create_model(
"AgentResponse",
a2a_ids=(
tuple[DynamicLiteral, ...], # type: ignore[valid-type]
Field(
default_factory=tuple,
max_length=len(agent_ids),
description="A2A agent IDs to delegate to.",
),
),
message=(
str,
Field(
description="The message content. If is_a2a=true, this is sent to the A2A agent. If is_a2a=false, this is your final answer ending the conversation."
),
),
is_a2a=(
bool,
Field(
description="Set to false when the remote agent has answered your question - extract their answer and return it as your final message. Set to true ONLY if you need to ask a NEW, DIFFERENT question. NEVER repeat the same request - if the conversation history shows the agent already answered, set is_a2a=false immediately."
),
),
__base__=BaseModel,
)
def extract_a2a_agent_ids_from_config(
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
) -> tuple[list[A2AClientConfigTypes], tuple[str, ...]]:
"""Extract A2A agent IDs from A2A configuration.
Filters out A2AServerConfig since it doesn't have an endpoint for delegation.
Args:
a2a_config: A2A configuration (any type).
Returns:
Tuple of client A2A configs list and agent endpoint IDs.
"""
if a2a_config is None:
return []
return [], ()
configs: list[A2AConfigTypes]
if isinstance(a2a_config, (A2AConfig, A2AClientConfig, A2AServerConfig)):
@@ -27,6 +77,24 @@ def extract_a2a_client_configs(
else:
configs = a2a_config
return [
client_configs: list[A2AClientConfigTypes] = [
config for config in configs if isinstance(config, (A2AConfig, A2AClientConfig))
]
return client_configs, tuple(config.endpoint for config in client_configs)
def get_a2a_agents_and_response_model(
a2a_config: list[A2AConfigTypes] | A2AConfigTypes | None,
) -> tuple[list[A2AClientConfigTypes], type[BaseModel] | None]:
"""Get A2A agent configs and response model.
Args:
a2a_config: A2A configuration (any type).
Returns:
Tuple of client A2A configs and response model.
"""
a2a_agents, agent_ids = extract_a2a_agent_ids_from_config(a2a_config=a2a_config)
return a2a_agents, create_agent_response_model(agent_ids)

File diff suppressed because it is too large Load Diff

View File

@@ -111,6 +111,12 @@ from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.training_handler import CrewTrainingHandler
try:
from crewai.a2a.types import AgentResponseProtocol
except ImportError:
AgentResponseProtocol = None # type: ignore[assignment, misc]
if TYPE_CHECKING:
from crewai_files import FileInput
@@ -214,7 +220,11 @@ class Agent(BaseAgent):
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
] = Field(
description="Language model that will run the agent.",
default=None,
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
)
system_template: str | None = Field(
default=None, description="System format for the agent."
)
@@ -424,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
@@ -436,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
)
@@ -444,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:
@@ -626,7 +650,15 @@ class Agent(BaseAgent):
result = process_tool_results(self, result)
output_for_event = result if isinstance(result, str) else str(result)
output_for_event = result
if (
AgentResponseProtocol is not None
and isinstance(result, BaseModel)
and isinstance(result, AgentResponseProtocol)
):
output_for_event = str(result.message)
elif not isinstance(result, str):
output_for_event = str(result)
crewai_event_bus.emit(
self,

View File

@@ -51,7 +51,10 @@ class LangGraphAgentAdapter(BaseAgentAdapter):
_graph: Any = PrivateAttr(default=None)
_memory: Any = PrivateAttr(default=None)
_max_iterations: int = PrivateAttr(default=10)
function_calling_llm: Any = Field(default=None)
function_calling_llm: Any = Field(
default=None,
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
)
step_callback: SerializableCallable | None = Field(default=None)
model: str = Field(default="gpt-4o")

View File

@@ -60,7 +60,10 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
_openai_agent: OpenAIAgentProtocol = PrivateAttr()
_logger: Logger = PrivateAttr(default_factory=Logger)
_active_thread: str | None = PrivateAttr(default=None)
function_calling_llm: Any = Field(default=None)
function_calling_llm: Any = Field(
default=None,
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
)
step_callback: Any = Field(default=None)
_tool_adapter: OpenAIAgentToolAdapter = PrivateAttr()
_converter_adapter: OpenAIConverterAdapter = PrivateAttr()

View File

@@ -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]:

View File

@@ -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(), "
@@ -251,7 +258,11 @@ class Crew(FlowTrackable, BaseModel):
str | LLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
] = Field(
description="Language model that will run the agent.",
default=None,
deprecated="function_calling_llm is deprecated and will be removed in a future release.",
)
config: Json[dict[str, Any]] | dict[str, Any] | None = Field(default=None)
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
share_crew: bool | None = Field(default=False)
@@ -318,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 "
@@ -337,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(
@@ -473,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
@@ -522,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:

View File

@@ -6,6 +6,14 @@ import time
from typing import Any
import uuid
from crewai_core.plus_api import (
TraceBatchInitPayload,
TraceBatchMetadata,
TraceEventsPayload,
TraceExecutionContext,
TraceExecutionMetadata,
TraceFinalizePayload,
)
from crewai_core.settings import Settings
from rich.console import Console
from rich.panel import Panel
@@ -123,25 +131,27 @@ class TraceBatchManager:
return None
try:
payload = {
execution_context: TraceExecutionContext = {
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
"crew_name": execution_metadata.get("crew_name", None),
"flow_name": execution_metadata.get("flow_name", None),
"crewai_version": self.current_batch.version,
"privacy_level": user_context.get("privacy_level", "standard"),
}
execution_metadata_payload: TraceExecutionMetadata = {
"expected_duration_estimate": execution_metadata.get(
"expected_duration_estimate", 300
),
"agent_count": execution_metadata.get("agent_count", 0),
"task_count": execution_metadata.get("task_count", 0),
"flow_method_count": execution_metadata.get("flow_method_count", 0),
"execution_started_at": datetime.now(timezone.utc).isoformat(),
}
payload: TraceBatchInitPayload = {
"trace_id": self.current_batch.batch_id,
"execution_type": execution_metadata.get("execution_type", "crew"),
"execution_context": {
"crew_fingerprint": execution_metadata.get("crew_fingerprint"),
"crew_name": execution_metadata.get("crew_name", None),
"flow_name": execution_metadata.get("flow_name", None),
"crewai_version": self.current_batch.version,
"privacy_level": user_context.get("privacy_level", "standard"),
},
"execution_metadata": {
"expected_duration_estimate": execution_metadata.get(
"expected_duration_estimate", 300
),
"agent_count": execution_metadata.get("agent_count", 0),
"task_count": execution_metadata.get("task_count", 0),
"flow_method_count": execution_metadata.get("flow_method_count", 0),
"execution_started_at": datetime.now(timezone.utc).isoformat(),
},
"execution_context": execution_context,
"execution_metadata": execution_metadata_payload,
}
if use_ephemeral:
payload["ephemeral_trace_id"] = self.current_batch.batch_id
@@ -264,13 +274,14 @@ class TraceBatchManager:
if not self.plus_api or not self.trace_batch_id or not self.event_buffer:
return 500
try:
payload = {
batch_metadata: TraceBatchMetadata = {
"events_count": len(self.event_buffer),
"batch_sequence": 1,
"is_final_batch": False,
}
payload: TraceEventsPayload = {
"events": [event.to_dict() for event in self.event_buffer],
"batch_metadata": {
"events_count": len(self.event_buffer),
"batch_sequence": 1,
"is_final_batch": False,
},
"batch_metadata": batch_metadata,
}
response = (
@@ -364,7 +375,7 @@ class TraceBatchManager:
return
try:
payload = {
payload: TraceFinalizePayload = {
"status": "completed",
"duration_ms": self.calculate_duration("execution"),
"final_event_count": events_count,

View File

@@ -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

View File

@@ -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()

View File

@@ -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__(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {}

View File

@@ -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.",

View File

@@ -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] = {}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 = {}

View File

@@ -121,11 +121,11 @@ def _kickoff_with_a2a_support(
Returns:
LiteAgentOutput from either local execution or A2A delegation.
"""
from crewai.a2a.utils.response_model import extract_a2a_client_configs
from crewai.a2a.utils.response_model import get_a2a_agents_and_response_model
from crewai.a2a.wrapper import _execute_task_with_a2a
from crewai.task import Task
a2a_agents = extract_a2a_client_configs(agent.a2a)
a2a_agents, agent_response_model = get_a2a_agents_and_response_model(agent.a2a)
if not a2a_agents:
return original_kickoff(messages, response_format, input_files)
@@ -160,6 +160,7 @@ def _kickoff_with_a2a_support(
a2a_agents=a2a_agents,
original_fn=task_to_kickoff_adapter,
task=fake_task,
agent_response_model=agent_response_model,
context=None,
tools=None,
extension_registry=extension_registry,

View File

@@ -940,6 +940,21 @@ class LLM(BaseLLM):
self._track_token_usage_internal(usage_info)
self._handle_streaming_callbacks(callbacks, usage_info, last_chunk)
if accumulated_tool_args and not available_functions:
tool_calls_list: list[ChatCompletionDeltaToolCall] = [
ChatCompletionDeltaToolCall(
index=idx,
function=Function(
name=tool_arg.function.name,
arguments=tool_arg.function.arguments,
),
)
for idx, tool_arg in sorted(accumulated_tool_args.items())
if tool_arg.function.name
]
if tool_calls_list:
return tool_calls_list
if not tool_calls or not available_functions:
if response_model and self.is_litellm:
instructor_instance = InternalInstructor(
@@ -1535,8 +1550,7 @@ class LLM(BaseLLM):
if usage_info:
self._track_token_usage_internal(usage_info)
if accumulated_tool_args and available_functions:
# Convert accumulated tool args to ChatCompletionDeltaToolCall objects
if accumulated_tool_args:
tool_calls_list: list[ChatCompletionDeltaToolCall] = [
ChatCompletionDeltaToolCall(
index=idx,
@@ -1545,21 +1559,24 @@ class LLM(BaseLLM):
arguments=tool_arg.function.arguments,
),
)
for idx, tool_arg in accumulated_tool_args.items()
for idx, tool_arg in sorted(accumulated_tool_args.items())
if tool_arg.function.name
]
if tool_calls_list:
result = self._handle_streaming_tool_calls(
tool_calls=tool_calls_list,
accumulated_tool_args=accumulated_tool_args,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
)
if result is not None:
return result
if available_functions:
result = self._handle_streaming_tool_calls(
tool_calls=tool_calls_list,
accumulated_tool_args=accumulated_tool_args,
available_functions=available_functions,
from_task=from_task,
from_agent=from_agent,
response_id=response_id,
)
if result is not None:
return result
else:
return tool_calls_list
usage_dict = self._usage_to_dict(usage_info)
self._handle_emit_call_events(

View File

@@ -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

View File

@@ -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).",

View File

@@ -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",
]

View 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

View File

@@ -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

View 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)

View File

@@ -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)

View File

@@ -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

View File

@@ -13,6 +13,7 @@ import sys
import types
from typing import Any, cast, get_type_hints
from crewai_core.plus_api import AvailableExport, EnvVarEntry, ToolMetadata
from crewai_core.project import (
get_project_description as get_project_description,
get_project_name as get_project_name,
@@ -279,7 +280,7 @@ def is_valid_tool(obj: Any) -> bool:
return isinstance(obj, Tool)
def extract_available_exports(dir_path: str = "src") -> list[dict[str, Any]]:
def extract_available_exports(dir_path: str = "src") -> list[AvailableExport]:
"""Extract available tool classes from the project's __init__.py files.
Only includes classes that inherit from BaseTool or functions decorated with @tool.
@@ -338,7 +339,7 @@ def _load_module_from_file(
sys.modules.pop(module_name, None)
def _load_tools_from_init(init_file: Path) -> list[dict[str, Any]]:
def _load_tools_from_init(init_file: Path) -> list[AvailableExport]:
"""Load and validate tools from a given __init__.py file."""
try:
with _load_module_from_file(init_file) as module:
@@ -392,7 +393,7 @@ def _print_no_tools_warning() -> None:
)
def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
def extract_tools_metadata(dir_path: str = "src") -> list[ToolMetadata]:
"""
Extract rich metadata from tool classes in the project.
@@ -404,7 +405,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
- init_params_schema: JSON Schema for __init__ params (filtered)
- env_vars: List of environment variable dicts
"""
tools_metadata: list[dict[str, Any]] = []
tools_metadata: list[ToolMetadata] = []
for init_file in Path(dir_path).glob("**/__init__.py"):
tools = _extract_tool_metadata_from_init(init_file)
@@ -413,7 +414,7 @@ def extract_tools_metadata(dir_path: str = "src") -> list[dict[str, Any]]:
return tools_metadata
def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
def _extract_tool_metadata_from_init(init_file: Path) -> list[ToolMetadata]:
"""
Load module from init file and extract metadata from valid tool classes.
"""
@@ -428,7 +429,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
if not exported_names:
return []
tools_metadata = []
tools_metadata: list[ToolMetadata] = []
for name in exported_names:
obj = getattr(module, name, None)
if obj is None or not (
@@ -446,7 +447,7 @@ def _extract_tool_metadata_from_init(init_file: Path) -> list[dict[str, Any]]:
return []
def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None:
def _extract_single_tool_metadata(tool_class: type) -> ToolMetadata | None:
"""
Extract metadata from a single tool class.
"""
@@ -470,19 +471,17 @@ def _extract_single_tool_metadata(tool_class: type) -> dict[str, Any] | None:
except (TypeError, ValueError):
module = tool_class.__module__
return {
"name": tool_class.__name__,
"module": module,
"humanized_name": _extract_field_default(
fields.get("name"), fallback=tool_class.__name__
return ToolMetadata(
name=tool_class.__name__,
module=module,
humanized_name=str(
_extract_field_default(fields.get("name"), fallback=tool_class.__name__)
),
"description": str(
_extract_field_default(fields.get("description"))
).strip(),
"run_params_schema": _extract_run_params_schema(fields.get("args_schema")),
"init_params_schema": _extract_init_params_schema(tool_class),
"env_vars": _extract_env_vars(fields.get("env_vars")),
}
description=str(_extract_field_default(fields.get("description"))).strip(),
run_params_schema=_extract_run_params_schema(fields.get("args_schema")),
init_params_schema=_extract_init_params_schema(tool_class),
env_vars=_extract_env_vars(fields.get("env_vars")),
)
except Exception:
return None
@@ -597,7 +596,7 @@ def _extract_init_params_schema(tool_class: type) -> dict[str, Any]:
return {}
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[dict[str, Any]]:
def _extract_env_vars(env_vars_field: dict[str, Any] | None) -> list[EnvVarEntry]:
"""
Extract environment variable definitions from env_vars field.
"""

View File

@@ -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(

View File

@@ -1,14 +1,13 @@
"""Tests for A2A delegation tool behavior, including trust_remote_completion_status."""
"""Test trust_remote_completion_status flag in A2A wrapper."""
from unittest.mock import MagicMock, patch
import pytest
from crewai.a2a.config import A2AClientConfig, A2AConfig
from crewai.a2a.config import A2AConfig
try:
from a2a.types import TaskState # noqa: F401
from a2a.types import Message, Role
A2A_SDK_INSTALLED = True
except ImportError:
@@ -16,126 +15,141 @@ except ImportError:
def _create_mock_agent_card(name: str = "Test", url: str = "http://test-endpoint.com/"):
"""Create a mock agent card with the attributes A2ADelegationTool reads."""
"""Create a mock agent card with proper model_dump behavior."""
mock_card = MagicMock()
mock_card.name = name
mock_card.url = url
mock_card.description = "A test agent"
mock_card.skills = []
mock_card.model_dump.return_value = {"name": name, "url": url}
mock_card.model_dump_json.return_value = f'{{"name": "{name}", "url": "{url}"}}'
return mock_card
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_delegation_tool_returns_remote_result_on_completion():
"""A successful remote completion is returned to the local LLM as the tool result."""
from a2a.types import TaskState
def test_trust_remote_completion_status_true_returns_directly():
"""When trust_remote_completion_status=True and A2A returns completed, return result directly."""
from crewai.a2a.wrapper import _delegate_to_a2a
from crewai.a2a.types import AgentResponseProtocol
from crewai import Agent, Task
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
config = A2AClientConfig(endpoint="http://test-endpoint.com")
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
trust_remote_completion_status=True,
)
agent = Agent(
role="test manager",
goal="coordinate",
backstory="test",
a2a=a2a_config,
)
task = Task(description="test", expected_output="test", agent=agent)
card = _create_mock_agent_card()
state = A2ADelegationState(agent=agent, task=task)
tools = build_a2a_tools([config], {config.endpoint: card}, state)
assert len(tools) == 1
tool = tools[0]
class MockResponse:
is_a2a = True
message = "Please help"
a2a_ids = ["http://test-endpoint.com/"]
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
with (
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
):
mock_card = _create_mock_agent_card()
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
# A2A returns completed
mock_execute.return_value = {
"status": TaskState.completed,
"status": "completed",
"result": "Done by remote",
"history": [],
}
result = tool._run(message="Please help")
assert result == "Done by remote"
assert mock_execute.call_count == 1
# This should return directly without checking LLM response
result = _delegate_to_a2a(
self=agent,
agent_response=MockResponse(),
task=task,
original_fn=lambda *args, **kwargs: "fallback",
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_card},
original_task_description="test",
)
assert result == "Done by remote"
assert mock_execute.call_count == 1
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_delegation_tool_records_completed_task_in_references():
"""When a remote task completes with a task_id, it goes into reference_task_ids."""
from a2a.types import TaskState
def test_trust_remote_completion_status_false_continues_conversation():
"""When trust_remote_completion_status=False and A2A returns completed, ask server agent."""
from crewai.a2a.wrapper import _delegate_to_a2a
from crewai import Agent, Task
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
config = A2AClientConfig(endpoint="http://test-endpoint.com")
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
trust_remote_completion_status=False,
)
agent = Agent(
role="test manager",
goal="coordinate",
backstory="test",
a2a=a2a_config,
)
task = Task(description="test", expected_output="test", agent=agent)
card = _create_mock_agent_card()
state = A2ADelegationState(agent=agent, task=task)
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
class MockResponse:
is_a2a = True
message = "Please help"
a2a_ids = ["http://test-endpoint.com/"]
history_msg = MagicMock()
history_msg.task_id = "remote-task-1"
history_msg.context_id = "ctx-1"
call_count = 0
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
def mock_original_fn(self, task, context, tools):
nonlocal call_count
call_count += 1
if call_count == 1:
# Server decides to finish
return '{"is_a2a": false, "message": "Server final answer", "a2a_ids": []}'
return "unexpected"
with (
patch("crewai.a2a.wrapper.execute_a2a_delegation") as mock_execute,
patch("crewai.a2a.wrapper._fetch_agent_cards_concurrently") as mock_fetch,
):
mock_card = _create_mock_agent_card()
mock_fetch.return_value = ({"http://test-endpoint.com/": mock_card}, {})
# A2A returns completed
mock_execute.return_value = {
"status": TaskState.completed,
"result": "Done",
"history": [history_msg],
}
tool._run(message="Please help")
endpoint_state = state._per_endpoint[config.endpoint]
assert "remote-task-1" in endpoint_state.reference_task_ids
assert endpoint_state.task_id is None
assert task.config is not None
assert task.config["reference_task_ids"] == ["remote-task-1"]
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_delegation_tool_returns_error_message_on_failure():
"""A non-completed/non-input-required status surfaces as a readable error string."""
from a2a.types import TaskState
from crewai import Agent, Task
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
config = A2AClientConfig(endpoint="http://test-endpoint.com")
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
task = Task(description="test", expected_output="test", agent=agent)
card = _create_mock_agent_card()
state = A2ADelegationState(agent=agent, task=task)
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
with patch("crewai.a2a.tools.execute_a2a_delegation") as mock_execute:
mock_execute.return_value = {
"status": TaskState.failed,
"error": "remote agent unreachable",
"status": "completed",
"result": "Done by remote",
"history": [],
}
result = tool._run(message="Please help")
assert "remote agent unreachable" in result
result = _delegate_to_a2a(
self=agent,
agent_response=MockResponse(),
task=task,
original_fn=mock_original_fn,
context=None,
tools=None,
agent_cards={"http://test-endpoint.com/": mock_card},
original_task_description="test",
)
# Should call original_fn to get server response
assert call_count >= 1
assert result == "Server final answer"
@pytest.mark.skipif(not A2A_SDK_INSTALLED, reason="Requires a2a-sdk to be installed")
def test_delegation_tool_respects_max_turns_via_usage_count():
"""A2AConfig.max_turns wires through to BaseTool.max_usage_count."""
from crewai import Agent, Task
from crewai.a2a.tools import A2ADelegationState, build_a2a_tools
config = A2AClientConfig(endpoint="http://test-endpoint.com", max_turns=2)
agent = Agent(role="manager", goal="coordinate", backstory="test", a2a=config)
task = Task(description="test", expected_output="test", agent=agent)
card = _create_mock_agent_card()
state = A2ADelegationState(agent=agent, task=task)
[tool] = build_a2a_tools([config], {config.endpoint: card}, state)
assert tool.max_usage_count == 2
def test_default_trust_remote_completion_status_is_false():
"""Verify that default value of trust_remote_completion_status is False."""
a2a_config = A2AConfig(endpoint="http://test-endpoint.com")
a2a_config = A2AConfig(
endpoint="http://test-endpoint.com",
)
assert a2a_config.trust_remote_completion_status is False

View 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

View 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]

View 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"

View File

@@ -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"])

View File

@@ -624,12 +624,15 @@ def test_handle_streaming_tool_calls_no_available_functions(
],
tools=[get_weather_tool_schema],
)
assert response == ""
assert isinstance(response, list)
assert len(response) == 1
assert response[0].function.name == "get_weather"
assert response[0].function.arguments == '{"location":"New York, NY"}'
assert_event_count(
mock_emit=mock_emit,
expected_stream_chunk=9,
expected_completed_llm_call=1,
expected_completed_llm_call=0,
expected_final_chunk_result='{"location":"New York, NY"}',
)

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.5a5"
__version__ = "1.14.5"

View File

@@ -3,6 +3,7 @@
from collections.abc import Mapping
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
@@ -355,8 +356,19 @@ def update_pyproject_dependencies(
workspace_packages = _DEFAULT_WORKSPACE_PACKAGES + (extra_packages or [])
current_extra: str | None = None
extra_header = re.compile(r"^\s*([A-Za-z0-9_-]+)\s*=\s*\[")
for i, line in enumerate(lines):
match = extra_header.match(line)
if match:
current_extra = match.group(1)
elif line.strip().startswith("]"):
current_extra = None
for pkg in workspace_packages:
if pkg == "crewai-files" and current_extra == "file-processing":
continue
if f"{pkg}==" in line:
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
@@ -732,18 +744,23 @@ def _is_prerelease(version: str) -> bool:
return any(indicator in v for indicator in _PRERELEASE_INDICATORS)
def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
def get_commits_from_last_tag(
tag_name: str, version: str, cwd: Path | None = None
) -> tuple[str, str]:
"""Get commits from the last tag, excluding current version.
Args:
tag_name: Current tag name (e.g., "v1.0.0").
version: Current version (e.g., "1.0.0").
cwd: Directory to run git commands in (defaults to current).
Returns:
Tuple of (commit_range, commits) where commits is newline-separated.
"""
try:
all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n")
all_tags = run_command(
["git", "tag", "--sort=-version:refname"], cwd=cwd
).split("\n")
prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"]
if not _is_prerelease(version):
@@ -752,22 +769,30 @@ def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]:
if prev_tags:
last_tag = prev_tags[0]
commit_range = f"{last_tag}..HEAD"
commits = run_command(["git", "log", commit_range, "--pretty=format:%s"])
commits = run_command(
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
)
else:
commit_range = "HEAD"
commits = run_command(["git", "log", "--pretty=format:%s"])
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
except subprocess.CalledProcessError:
commit_range = "HEAD"
commits = run_command(["git", "log", "--pretty=format:%s"])
commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd)
return commit_range, commits
def get_github_contributors(commit_range: str) -> list[str]:
def get_github_contributors(
commit_range: str,
repo: str = "crewAIInc/crewAI",
cwd: Path | None = None,
) -> list[str]:
"""Get GitHub usernames from commit range using GitHub API.
Args:
commit_range: Git commit range (e.g., "abc123..HEAD").
repo: GitHub repo in ``owner/name`` form to resolve commits against.
cwd: Directory to run git commands in (defaults to current).
Returns:
List of GitHub usernames sorted alphabetically.
@@ -779,10 +804,10 @@ def get_github_contributors(commit_range: str) -> list[str]:
gh_token = None
g = Github(login_or_token=gh_token) if gh_token else Github()
github_repo = g.get_repo("crewAIInc/crewAI")
github_repo = g.get_repo(repo)
commit_shas = run_command(
["git", "log", commit_range, "--pretty=format:%H"]
["git", "log", commit_range, "--pretty=format:%H"], cwd=cwd
).split("\n")
contributors = set()
@@ -922,9 +947,26 @@ def _generate_release_notes(
version: str,
tag_name: str,
no_edit: bool,
cwd: Path | None = None,
gh_repo: str = "crewAIInc/crewAI",
openai_client: OpenAI | None = None,
bump_already_done: bool = True,
) -> tuple[str, OpenAI, bool]:
"""Generate, display, and optionally edit release notes.
Args:
version: Version being released.
tag_name: Tag name for the release.
no_edit: Skip the interactive edit prompt.
cwd: Directory to run git commands in (defaults to current).
gh_repo: GitHub repo (``owner/name``) for resolving contributors.
openai_client: Reuse an existing OpenAI client if provided.
bump_already_done: True when the ``feat: bump versions to <version>``
commit for the current release is already in history (the real
release path). False in previews where no bump exists yet — the
most recent bump commit is the *previous* version and must be
used as the range start.
Returns:
Tuple of (release_notes, openai_client, is_prerelease).
"""
@@ -939,7 +981,8 @@ def _generate_release_notes(
"log",
"--grep=^feat: bump versions to",
"--format=%H %s",
]
],
cwd=cwd,
)
bump_entries = [
line for line in prev_bump_output.strip().split("\n") if line.strip()
@@ -947,7 +990,8 @@ def _generate_release_notes(
is_stable = not _is_prerelease(version)
prev_commit = None
for entry in bump_entries[1:]:
scan_entries = bump_entries[1:] if bump_already_done else bump_entries
for entry in scan_entries:
bump_ver = entry.split("feat: bump versions to", 1)[-1].strip()
if is_stable and _is_prerelease(bump_ver):
continue
@@ -957,7 +1001,7 @@ def _generate_release_notes(
if prev_commit:
commit_range = f"{prev_commit}..HEAD"
commits = run_command(
["git", "log", commit_range, "--pretty=format:%s"]
["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd
)
commit_lines = [
@@ -967,14 +1011,21 @@ def _generate_release_notes(
]
commits = "\n".join(commit_lines)
else:
commit_range, commits = get_commits_from_last_tag(tag_name, version)
commit_range, commits = get_commits_from_last_tag(
tag_name, version, cwd=cwd
)
except subprocess.CalledProcessError:
commit_range, commits = get_commits_from_last_tag(tag_name, version)
commit_range, commits = get_commits_from_last_tag(
tag_name, version, cwd=cwd
)
github_contributors = get_github_contributors(commit_range)
github_contributors = get_github_contributors(
commit_range, repo=gh_repo, cwd=cwd
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
if openai_client is None:
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
if commits.strip():
contributors_section = ""
@@ -1532,7 +1583,13 @@ def _wait_for_pr_merged(branch: str, cwd: Path) -> None:
time.sleep(_PR_MERGE_POLL_INTERVAL)
def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None:
def _release_enterprise(
version: str,
is_prerelease: bool,
dry_run: bool,
no_edit: bool = False,
openai_client: OpenAI | None = None,
) -> None:
"""Clone the enterprise repo, bump versions, and create a release PR.
Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and
@@ -1542,6 +1599,8 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
version: New version string.
is_prerelease: Whether this is a pre-release version.
dry_run: Show what would be done without making changes.
no_edit: Skip the interactive release-notes edit prompt.
openai_client: Reuse OpenAI client from earlier phases if available.
"""
if (
not _ENTERPRISE_REPO
@@ -1559,7 +1618,6 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
)
if dry_run:
console.print(f"[dim][DRY RUN][/dim] Would clone {enterprise_repo}")
for d in _ENTERPRISE_VERSION_DIRS:
console.print(f"[dim][DRY RUN][/dim] Would update versions in {d}")
console.print(
@@ -1570,6 +1628,26 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
"[dim][DRY RUN][/dim] Would create bump PR, wait for merge, "
"then tag and release"
)
with tempfile.TemporaryDirectory() as tmp:
repo_dir = Path(tmp) / enterprise_repo.split("/")[-1]
console.print(f"\nCloning {enterprise_repo} (read-only preview)...")
run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)])
console.print(f"[green]✓[/green] Cloned {enterprise_repo}")
_generate_release_notes(
version,
version,
no_edit,
cwd=repo_dir,
gh_repo=enterprise_repo,
openai_client=openai_client,
bump_already_done=False,
)
console.print(
"[dim][DRY RUN][/dim] Would tag and create GitHub release "
"with the notes above"
)
return
with tempfile.TemporaryDirectory() as tmp:
@@ -1682,8 +1760,18 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
run_command(["git", "pull"], cwd=repo_dir)
tag_name = version
release_notes, _, _ = _generate_release_notes(
version,
tag_name,
no_edit,
cwd=repo_dir,
gh_repo=enterprise_repo,
openai_client=openai_client,
)
run_command(
["git", "tag", "-a", tag_name, "-m", f"Release {version}"],
["git", "tag", "-a", tag_name, "-m", release_notes],
cwd=repo_dir,
)
run_command(["git", "push", "origin", tag_name], cwd=repo_dir)
@@ -1699,7 +1787,7 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
"--title",
tag_name,
"--notes",
f"Release {version}",
release_notes,
]
if is_prerelease:
gh_cmd.append("--prerelease")
@@ -1998,7 +2086,7 @@ def tag(dry_run: bool, no_edit: bool) -> None:
console.print("[green]✓[/green] main branch up to date")
release_notes, openai_client, is_prerelease = _generate_release_notes(
version, tag_name, no_edit
version, tag_name, no_edit, bump_already_done=True
)
docs_branch = _update_docs_and_create_pr(
@@ -2109,7 +2197,7 @@ def release(
if skip_to_enterprise:
try:
_release_enterprise(version, is_prerelease, dry_run)
_release_enterprise(version, is_prerelease, dry_run, no_edit=no_edit)
except BaseException as e:
_print_release_error(e)
_resume_hint(
@@ -2205,7 +2293,7 @@ def release(
console.print("[green]✓[/green] main branch up to date")
release_notes, openai_client, is_prerelease = _generate_release_notes(
version, tag_name, no_edit
version, tag_name, no_edit, bump_already_done=not dry_run
)
docs_branch = _update_docs_and_create_pr(
@@ -2259,7 +2347,13 @@ def release(
if not skip_enterprise:
try:
_release_enterprise(version, is_prerelease, dry_run)
_release_enterprise(
version,
is_prerelease,
dry_run,
no_edit=no_edit,
openai_client=openai_client,
)
except BaseException as e:
_print_release_error(e)
_resume_hint(

View File

@@ -282,6 +282,25 @@ class TestUpdatePyprojectDependencies:
assert '"crewai-files==2.0.0"' in result
assert '"requests>=2.0"' in result
def test_skips_crewai_files_in_file_processing_extra(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
dedent("""\
[project.optional-dependencies]
file-processing = [
"crewai-files==1.0.0",
]
other = [
"crewai-files==1.0.0",
]
""")
)
update_pyproject_dependencies(pyproject, "2.0.0")
result = pyproject.read_text()
assert '"crewai-files==1.0.0"' in result
assert '"crewai-files==2.0.0"' in result
def test_leaves_bare_crewai_pin_alone(self, tmp_path: Path) -> None:
"""`crewai==` must not collide with `crewai-core==` etc."""
pyproject = tmp_path / "pyproject.toml"

View File

@@ -185,8 +185,10 @@ exclude-newer = "3 days"
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
# 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.7.31 has GHSA-rr7j-v2q5-chgv (streaming token redaction bypass); force 0.7.31+.
# 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 = [
@@ -203,8 +205,10 @@ override-dependencies = [
"uv>=0.11.6,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.50,<4",
"langsmith>=0.7.31,<0.8",
"langsmith>=0.8.0,<1",
"authlib>=1.6.11",
"pip>=26.1.1",
"paramiko>=5.0.0",
]
[tool.uv.workspace]

30
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-05-08T16:33:02.834109Z"
exclude-newer = "2026-05-17T14:20:01.778505Z"
exclude-newer-span = "P3D"
[manifest]
@@ -31,10 +31,12 @@ overrides = [
{ name = "gitpython", specifier = ">=3.1.50,<4" },
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
{ name = "langsmith", specifier = ">=0.7.31,<0.8" },
{ 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" },
@@ -3268,11 +3270,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
@@ -3888,7 +3890,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2
[[package]]
name = "langsmith"
version = "0.7.32"
version = "0.8.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@@ -3901,9 +3903,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/1e8ea5e8bab2a65fa95bd36229ef38e8723ec46e430e20ca2d953487a7f1/langsmith-0.8.3.tar.gz", hash = "sha256:767ff7a8d136ed42926bf99059ac631dc6883542d6e3104b32e71c7625e1fa05", size = 4460330, upload-time = "2026-05-07T19:56:56.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
{ url = "https://files.pythonhosted.org/packages/98/a9/51e644c1f1dbc3dd7d22dfd6412eab206d538c81e024e4f287373544bdcb/langsmith-0.8.3-py3-none-any.whl", hash = "sha256:b2e40e308222fa0beb2dccee3b4b30bfee9062d7a4f20a3e3e93df3c51a08ab4", size = 399048, upload-time = "2026-05-07T19:56:53.994Z" },
]
[[package]]
@@ -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]]