Compare commits

..

20 Commits

Author SHA1 Message Date
Joao Moura
85a6ba66ca fix: tighten json crew deploy validation 2026-06-19 14:34:26 -07:00
Joao Moura
1fd4225eb2 fix: keep json deploy archives python-free 2026-06-19 14:34:26 -07:00
Joao Moura
ce3e94fe74 fix: keep json crew scaffolds python-free 2026-06-19 14:34:26 -07:00
Joao Moura
0bdf9ebf26 fix: scaffold deployable json crews 2026-06-19 14:34:26 -07:00
Vinicius Brasil
9db2d44766 Add typed output schemas for CrewAI tools (#6236)
Some checks are pending
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) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
Currently, tools have a strong input contract through `args_schema`, but no
output contract. This means that anything a tool outputs is converted to
string.

Not only the contract is weak, but the "invisible" conversion to string can
have unexpected effects when the tool returns complex objects like dicts and
arrays.

With this PR, a tool can _optionally_ define an output contract with
`output_schema`. CrewAI validates the raw result and sends the agent JSON.

```python
class ProductResult(BaseModel):
    sku: str
    name: str
    in_stock: bool

class ProductLookupTool(BaseTool):
    name: str = "Product Lookup"
    description: str = "Look up product availability by SKU."

    def _run(self, sku: str) -> ProductResult:
        return ProductResult(sku=sku, name="USB-C dock", in_stock=True)
```

If the result does not match the schema, CrewAI warns and falls back to
`str(raw_result)` instead of failing the run:

```python
@tool("Product Lookup", output_schema=ProductResult)
def product_lookup(sku: str) -> dict[str, object]:
    return {"sku": sku, "name": "USB-C dock", "in_stock": True}

#=> RuntimeWarning: Failed to validate or serialize output from tool 'Bad Product Lookup' using output_schema 'ProductResult'... Falling back to str(raw_result).
```

This is additive and non-breaking. Existing tools do not need to change. Tools
without `output_schema` keep the old string behavior. Invalid typed outputs
warn and fall back to the old formatting path.
2026-06-19 14:33:51 -07:00
Jesse Miller
cf04181190 docs: add "One Card per Step" Studio page (AGE-107) (#6247)
* docs: add "One Card per Step" Studio page (AGE-107)

Document the merge of the task and agent nodes into a single step card on
the Studio canvas. Written as evergreen present-tense feature docs with a
dated rollout banner (June 24th) for the pre-launch customer announcement;
the banner is the only time-bound content and is flagged for removal after
ship. Added in edge + v1.14.7 across en, pt-BR, ko, and ar, with nav entries
in docs.json and three canvas/editor/swap screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: bump bedrock agentcore dependencies

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: alex-clawd <alex@crewai.com>
2026-06-19 13:10:25 -04:00
Vinicius Brasil
854c67d21c docs: snapshot and changelog for v1.14.8a2 (#6230)
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
2026-06-18 16:42:17 -07:00
Vinicius Brasil
f48a6389f1 feat: bump versions to 1.14.8a2 (#6229) 2026-06-18 16:37:27 -07:00
Vinicius Brasil
bc2c2a858c Add single agent action to Flow definitions (#6226)
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
* Add single agent action to Flow definitions

Lets a flow method build and run a single CrewAI agent directly, without
wrapping it in a crew. Same idea as the existing `crew` action, but for
one agent.

  methods:
    answer:
      do:
        call: agent
        with:
          role: Analyst
          goal: Answer questions
          backstory: Knows things.
          input: "${state.question}"
      start: true

* `input` is required and interpolated from flow state, like
  `${state.question}` or `${item}` inside an `each` loop
* optional `response_format` points at a Pydantic model (`{"python":
  "models.AnswerModel"}`) to get structured output
* `input` must be a string and its CEL is validated at load time, so bad
  expressions like `${state.}` fail early

* Simplify test code
2026-06-18 14:53:33 -07:00
Lucas Gomide
fa89ac428e docs: add Datadog integration guide with importable operations dashboard (#6225)
Adds a consolidated `datadog.mdx` under `docs/edge/{en,pt-BR,ko,ar}/enterprise/guides/`
covering both the Datadog Agent path (stdout JSON logs via `CREWAI_LOG_FORMAT=json`)
and the Datadog OTLP intake, with a JSON log schema reference and a ready-to-import
operations dashboard (`datadog_dashboard.json`). Reframes `capture_telemetry_logs.mdx`
to lead with OpenTelemetry as the vendor-neutral path and point readers to the new
Datadog page for that ecosystem's setup.
2026-06-18 16:18:42 -04:00
Vinicius Brasil
b0816e00b6 Validate flow CEL expressions at definition load time (#6224)
* Validate flow CEL expressions at definition load time

Promote CEL expression handling to a public Expression API and validate expressions when a FlowDefinition is built instead of when it executes.

Invalid CEL syntax or unknown roots now raise ValidationError from FlowDefinition.from_yaml() and FlowDefinition.from_dict(). Expressions may reference state and outputs, plus item inside each.do; bare identifiers are rejected as unknown roots.

For with values, the CEL contract is intentionally simple: after trimming whitespace, a string is evaluated as CEL only if it starts with ${ and ends with }. Anything else is treated as a literal value, so partial interpolation is not supported. If the content inside the wrapper is not valid CEL, validation fails.

Examples:

```text
"${state.topic}"          -> evaluated, returns state.topic
"topic is ${state.topic}" -> literal string
"${state.topic} suffix"   -> literal string
"${'a'}${'b'}"              -> invalid CEL
```

* Honor explicit empty-context overrides in evaluate() / render_template()
2026-06-18 12:18:22 -07:00
João Moura
8153b67f5d docs: snapshot and changelog for v1.14.8a1 (#6223) 2026-06-18 14:46:37 -03:00
João Moura
c226722e22 feat: bump versions to 1.14.8a1 (#6222)
* test

* feat: bump versions to 1.14.8a1
2026-06-18 14:44:10 -03:00
Vinicius Brasil
b5e23a87f2 Add optional if expression to each.do steps (#6214)
* Use explicit name/action shape for each.do steps

* Add optional `if` expression to `each.do` steps

Lets a step inside an `each` action run conditionally based on a CEL
expression evaluated against `item` and prior step `outputs`.
2026-06-18 10:33:13 -07:00
João Moura
504c5c9b04 JSON crew fixes (#6217)
* feat: update pyproject.toml to specify wheel targets

Added a new section to the pyproject.toml file to include only specific files in the wheel build, enhancing the packaging process. Updated tests to verify the inclusion of these targets.

* feat: add memory save event handling to activity log

Implemented event handlers for MemorySaveStartedEvent, MemorySaveCompletedEvent, and MemorySaveFailedEvent in the crew_run_tui module. This allows the application to log memory save operations, capturing their status and details in the activity log. Added corresponding tests to verify the correct logging behavior for successful and failed memory saves.

* feat: enhance memory save event handling in activity log

Added functionality to suppress nested memory save events and updated the handling of MemorySaveStartedEvent, MemorySaveCompletedEvent, and MemorySaveFailedEvent to improve logging accuracy. Introduced new tests to verify the correct behavior of memory save events, including scenarios for nested events and completion updates for timed-out entries.

* Fix memory save activity log handling

* Normalize alpha package versions

* Update scaffolded crew dependency

* feat: add button to copy setup instructions for CrewAI coding agents

Introduced a button in the documentation that allows users to easily copy setup instructions for CrewAI coding agents. The instructions include installation steps, environment setup, and best practices for using the CrewAI CLI. This enhancement aims to streamline the onboarding process for new users.

* Improve missing CrewAI install guidance

* fix: address pr review feedback

* fix: avoid mismatched memory save rows

* fix: wait for queued memory save events

* fix: avoid matching memory saves on missing ids

* chore: normalize prerelease version to 1.14.8a1
2026-06-18 14:14:54 -03:00
João Moura
c0fa66d182 docs: snapshot and changelog for v1.14.8a (#6216)
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
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-06-18 02:42:41 -03:00
João Moura
6c41d55fe2 feat: bump versions to 1.14.8a (#6215) 2026-06-18 02:39:42 -03:00
Vinicius Brasil
218dc82bf7 Replace flow diagnostics with logging (#6212)
This commit removes flow diagnostics from the definition. These were
used for logging only, and should not be coupled to the definition.
2026-06-17 19:37:52 -07:00
Vinicius Brasil
7374486f00 Document FlowDefinition fields in the JSON schema (#6198)
Add a description and examples to every FlowDefinition field and
standardize on `typing.Literal`, so the generated JSON schema documents
itself — each action discriminator, state branch, and config option
explains what it is and shows a realistic value.

Examples live on individual fields only, never at the model level, which
keeps the schema readable for tooling that renders field-level help.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:49:01 -07:00
Vinicius Brasil
5bd10ee2c4 Add script/code block action to FlowDefinition (#6197)
* Add script/code blocks to FlowDefinition

Let a Flow method run trusted inline Python with `call: script`. The code
is compiled once into a generated function and receives the runtime
values as arguments.

```yaml
methods:
  normalize:
    start: true
    do:
      call: script
      code: |
        import math
        state["rounded"] = math.ceil(state["raw_score"])
        return f"rounded:{state['rounded']}"
```

Even though this shares the same surface of tools (custom code), I
decided to make it opt-in for now, using
`CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1`.

* Address code review comments
2026-06-17 18:38:41 -07:00
90 changed files with 7225 additions and 1057 deletions

View File

@@ -29,30 +29,4 @@ jobs:
lib/crewai/src/crewai/cli/templates/**
**/*.json
**/test_durations/**
**/cassettes/**
python-diff-size:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Enforce Python diff size limit
env:
MAX: "1500"
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Three-dot base...head == merge-base(base, head)..head: matches GitHub's
# "Files changed" diff and ignores the synthetic merge commit at HEAD.
# Sum added + deleted lines across changed .py files; skip binaries ("-").
total=$(git diff --numstat "$BASE_SHA...$HEAD_SHA" -- '*.py' \
| awk '$1 != "-" && $2 != "-" { sum += $1 + $2 } END { print sum + 0 }')
echo "Python churn: $total lines (limit $MAX)"
if [ "$total" -gt "$MAX" ]; then
echo "::error::Python changes total $total lines, over the $MAX-line limit. Split into smaller PRs."
git diff --numstat "$BASE_SHA...$HEAD_SHA" -- '*.py' | sort -rn
exit 1
fi
**/cassettes/**

View File

@@ -398,6 +398,7 @@
"pages": [
"edge/en/enterprise/features/automations",
"edge/en/enterprise/features/crew-studio",
"edge/en/enterprise/features/merged-step-card",
"edge/en/enterprise/features/marketplace",
"edge/en/enterprise/features/agent-repositories",
"edge/en/enterprise/features/tools-and-integrations",
@@ -515,6 +516,7 @@
"edge/en/enterprise/guides/update-crew",
"edge/en/enterprise/guides/enable-crew-studio",
"edge/en/enterprise/guides/capture_telemetry_logs",
"edge/en/enterprise/guides/datadog",
"edge/en/enterprise/guides/azure-openai-setup",
"edge/en/enterprise/guides/vertex-ai-workload-identity-setup",
"edge/en/enterprise/guides/tool-repository",
@@ -921,6 +923,7 @@
"pages": [
"v1.14.7/en/enterprise/features/automations",
"v1.14.7/en/enterprise/features/crew-studio",
"v1.14.7/en/enterprise/features/merged-step-card",
"v1.14.7/en/enterprise/features/marketplace",
"v1.14.7/en/enterprise/features/agent-repositories",
"v1.14.7/en/enterprise/features/tools-and-integrations",
@@ -8547,6 +8550,7 @@
"pages": [
"edge/pt-BR/enterprise/features/automations",
"edge/pt-BR/enterprise/features/crew-studio",
"edge/pt-BR/enterprise/features/merged-step-card",
"edge/pt-BR/enterprise/features/marketplace",
"edge/pt-BR/enterprise/features/agent-repositories",
"edge/pt-BR/enterprise/features/tools-and-integrations",
@@ -8647,6 +8651,7 @@
"edge/pt-BR/enterprise/guides/update-crew",
"edge/pt-BR/enterprise/guides/enable-crew-studio",
"edge/pt-BR/enterprise/guides/capture_telemetry_logs",
"edge/pt-BR/enterprise/guides/datadog",
"edge/pt-BR/enterprise/guides/azure-openai-setup",
"edge/pt-BR/enterprise/guides/tool-repository",
"edge/pt-BR/enterprise/guides/custom-mcp-server",
@@ -9047,6 +9052,7 @@
"pages": [
"v1.14.7/pt-BR/enterprise/features/automations",
"v1.14.7/pt-BR/enterprise/features/crew-studio",
"v1.14.7/pt-BR/enterprise/features/merged-step-card",
"v1.14.7/pt-BR/enterprise/features/marketplace",
"v1.14.7/pt-BR/enterprise/features/agent-repositories",
"v1.14.7/pt-BR/enterprise/features/tools-and-integrations",
@@ -16410,6 +16416,7 @@
"pages": [
"edge/ko/enterprise/features/automations",
"edge/ko/enterprise/features/crew-studio",
"edge/ko/enterprise/features/merged-step-card",
"edge/ko/enterprise/features/marketplace",
"edge/ko/enterprise/features/agent-repositories",
"edge/ko/enterprise/features/tools-and-integrations",
@@ -16510,6 +16517,7 @@
"edge/ko/enterprise/guides/update-crew",
"edge/ko/enterprise/guides/enable-crew-studio",
"edge/ko/enterprise/guides/capture_telemetry_logs",
"edge/ko/enterprise/guides/datadog",
"edge/ko/enterprise/guides/azure-openai-setup",
"edge/ko/enterprise/guides/tool-repository",
"edge/ko/enterprise/guides/custom-mcp-server",
@@ -16922,6 +16930,7 @@
"pages": [
"v1.14.7/ko/enterprise/features/automations",
"v1.14.7/ko/enterprise/features/crew-studio",
"v1.14.7/ko/enterprise/features/merged-step-card",
"v1.14.7/ko/enterprise/features/marketplace",
"v1.14.7/ko/enterprise/features/agent-repositories",
"v1.14.7/ko/enterprise/features/tools-and-integrations",
@@ -24465,6 +24474,7 @@
"pages": [
"edge/ar/enterprise/features/automations",
"edge/ar/enterprise/features/crew-studio",
"edge/ar/enterprise/features/merged-step-card",
"edge/ar/enterprise/features/marketplace",
"edge/ar/enterprise/features/agent-repositories",
"edge/ar/enterprise/features/tools-and-integrations",
@@ -24565,6 +24575,7 @@
"edge/ar/enterprise/guides/update-crew",
"edge/ar/enterprise/guides/enable-crew-studio",
"edge/ar/enterprise/guides/capture_telemetry_logs",
"edge/ar/enterprise/guides/datadog",
"edge/ar/enterprise/guides/azure-openai-setup",
"edge/ar/enterprise/guides/tool-repository",
"edge/ar/enterprise/guides/custom-mcp-server",
@@ -24977,6 +24988,7 @@
"pages": [
"v1.14.7/ar/enterprise/features/automations",
"v1.14.7/ar/enterprise/features/crew-studio",
"v1.14.7/ar/enterprise/features/merged-step-card",
"v1.14.7/ar/enterprise/features/marketplace",
"v1.14.7/ar/enterprise/features/agent-repositories",
"v1.14.7/ar/enterprise/features/tools-and-integrations",

View File

@@ -4,6 +4,86 @@ description: "تحديثات المنتج والتحسينات وإصلاحات
icon: "clock"
mode: "wide"
---
<Update label="18 يونيو 2026">
## v1.14.8a2
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a2)
## ما الذي تغير
### الميزات
- إضافة إجراء عميل واحد إلى تعريفات التدفق
- التحقق من تعبيرات CEL للتدفق عند تحميل التعريف
### الوثائق
- إضافة دليل تكامل Datadog مع لوحة عمليات قابلة للاستيراد
- تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a1
## المساهمون
@joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="18 يونيو 2026">
## v1.14.8a1
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## ما الذي تغير
### الميزات
- إضافة تعبير if اختياري إلى خطوات each.do
### إصلاحات الأخطاء
- إصلاح مشكلات JSON crew
### الوثائق
- تحديث snapshot و changelog للإصدار v1.14.8a
## المساهمون
@joaomdmoura, @vinibrsl
</Update>
<Update label="17 يونيو 2026">
## v1.14.8a
[عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## ما الذي تغير
### الميزات
- إضافة إجراء كتلة نصية/كود إلى FlowDefinition
- إضافة إجراءات الطاقم إلى FlowDefinition
- إضافة إجراء مركب `each` إلى FlowDefinition
- تنفيذ دعم وضع DMN في إنشاء الطاقم وتنفيذه
- تحسين وظيفة إعادة تعيين الذاكرة ومعالجة الطاقم بتنسيق JSON
- إضافة تعبيرات إلى إجراءات FlowDefinition
- تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python
- دفع التغذية الراجعة البشرية من تعريف التدفق
- توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل
- إضافة `crewai run --definition` التجريبية للتدفقات
- دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON
- تقديم الطواقم بتنسيق JSON أولاً
### إصلاحات الأخطاء
- إصلاح أداة Exa المكررة
- إصلاح استخدام الرموز المجمعة عبر جميع استدعاءات LLM
- حل المشكلات المتعلقة بتحميل الطاقم ومنطق التحقق
### الوثائق
- توثيق حقول FlowDefinition في مخطط JSON
- تحديث وثائق التثبيت والبدء السريع لمشاريع الطاقم بتنسيق JSON أولاً
- تحديث سجل التغييرات والإصدار لـ v1.14.7
## المساهمون
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="11 يونيو 2026">
## v1.14.7

View File

@@ -0,0 +1,87 @@
---
title: بطاقة واحدة لكل خطوة
description: "كل خطوة على لوحة Studio هي بطاقة واحدة تجمع بين المهمة والوكيل الذي ينفّذها."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
</Note>
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة).
- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته).
الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة.
<Frame caption="بطاقة واحدة لكل خطوة: المهمة مع ملخص للوكيل المُعيَّن في التذييل.">
![بطاقات الخطوات الموحّدة على اللوحة](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## على اللوحة
تعرض كل بطاقة مطوية ما يلي:
- **اسم المهمة ووصفها** في الأعلى.
- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات.
لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به.
## في المحرّر
افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح.
<Frame caption="المحرّر الموسّع: قسم المهمة مفتوح، والوكيل ملخّص أسفله.">
![محرّر الخطوة الموسّع](/images/enterprise/merged-step-card-editor.png)
</Frame>
### المهمة — ماذا تفعل
مفتوحة افتراضيًا، لأنها ما تحرّره عادةً:
- **الاسم**
- **الوصف**
- **المخرجات المتوقعة**
- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة.
### الوكيل — من ينفّذها
يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي:
- **الدور والهدف والخلفية**
- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM.
<Tip>
الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له.
</Tip>
## التبديل مقابل تحرير الوكيل
هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة:
- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة.
- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*.
<Frame caption="التبديل يغيّر الوكيل الذي ينفّذ المهمة.">
![لوحة تبديل الوكيل](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك.
</Warning>
## ذات صلة
<CardGroup cols={2}>
<Card title="Crew Studio" href="/ar/enterprise/features/crew-studio" icon="pencil">
أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي.
</Card>
<Card title="مستودعات الوكلاء" href="/ar/enterprise/features/agent-repositories" icon="users">
إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك.
</Card>
</CardGroup>

View File

@@ -9,6 +9,10 @@ mode: "wide"
تتبع بيانات القياس [اتفاقيات OpenTelemetry GenAI الدلالية](https://opentelemetry.io/docs/specs/semconv/gen-ai/) بالإضافة إلى سمات خاصة بـ CrewAI.
<Tip>
تُعدّ OpenTelemetry **مسار المراقبة الموصى به** — محايدة تجاه الموردين، وتعمل مع أي خلفية متوافقة مع OTLP (Grafana, Honeycomb, NewRelic، أو مجمّعك الخاص). إذا كنت تستخدم Datadog تحديدًا، فراجع دليل [تكامل Datadog](./datadog) المخصص، الذي يغطي كلًا من مسار وكيل Datadog واستيعاب OTLP من Datadog.
</Tip>
## المتطلبات المسبقة
<CardGroup cols={2}>
@@ -41,17 +45,7 @@ mode: "wide"
<Frame>![تهيئة مجمّع OpenTelemetry](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — مضيف OTLP لموقع Datadog الخاص بك فقط، دون بروتوكول أو مسار. يقوم CrewAI ببناء نقطة نهاية HTTPS OTLP الكاملة نيابةً عنك. استخدم المضيف المطابق لـ [موقع Datadog](https://docs.datadoghq.com/getting_started/site/) الخاص بك:
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — مفتاح واجهة برمجة تطبيقات Datadog الخاص بك. راجع [كيفية إنشاء واحد](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
يصدّر تكامل Datadog **التتبعات**.
<Frame>![تهيئة مجمّع Datadog](/images/crewai-otel-collector-datadog.png)</Frame>
لإعداد Datadog، راجع دليل [تكامل Datadog](./datadog) المخصص — فهو يغطي كلًا من مسار وكيل Datadog (الموصى به، أرخص لحجم السجلات الكبير) واستيعاب OTLP من Datadog، مع خطوات تهيئة كاملة للمجمّع.
</Tab>
</Tabs>

View File

@@ -0,0 +1,295 @@
---
title: "تكامل Datadog"
description: "راقب عمليات نشر CrewAI AMP المُستضافة ذاتيًا في Datadog عبر وكيل Datadog أو استيعاب OTLP من Datadog — يوفر كلا المسارين نفس الواجهات المهيكلة لاستيراد لوحة معلومات العمليات الجاهزة."
icon: "dog"
mode: "wide"
---
<Note>
**الترجمة قيد التقدم** — يتم عرض المحتوى باللغة الإنجليزية.
</Note>
CrewAI ships first-class support for Datadog: two log-ingestion paths, a JSON log schema designed for cheap indexing, and a ready-made operations dashboard you can import in under five minutes.
<Note>
For vendor-neutral observability via any OTLP backend (Grafana, Honeycomb, your own collector), see [OpenTelemetry Export](./capture_telemetry_logs).
</Note>
## Choose a path
CrewAI supports two log-ingestion paths to Datadog — both are first-class and produce the same structured facets that power the dashboard. Pick the one that fits your infrastructure.
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
<Tab title="Datadog OTLP intake">
CrewAI AMP exports OpenTelemetry traffic directly to Datadog's OTLP endpoint with no Agent required. Logs and traces ride a single export pipeline configured in AMP's UI, using the same protocol you'd use for any other OTLP backend.
**Setup:**
1. In CrewAI AMP, go to **Settings → OpenTelemetry Collectors → Add Collector** and pick **Datadog**.
2. Configure the connection:
- **Datadog Site Domain** — your Datadog site's OTLP host only, no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
3. The Datadog template provisions **both signals at once** — when you save, AMP creates a traces collector at `/v1/traces` and a logs collector at `/v1/logs`, both sharing the same Datadog OTLP host and API key. You'll see them as two separate rows in your OTel collectors list.
4. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided. Then click **Save** — both collectors are created in one step.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
**Pick this path if** you'd rather not operate a Datadog Agent, you already use OTLP for traces and want one export pipeline, or you may later want to fan out the same telemetry to other backends (Grafana, Honeycomb, etc.) without changing your application setup.
</Tab>
</Tabs>
Either path lands the same structured facets in Datadog (`@automation_id`, `@kickoff_id`, `@execution_id`, `@automation_name`, `@crewai_version`, `@exception.type`, `@gen_ai.*`), so the dashboard works identically with either choice.
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
<CardGroup cols={2}>
<Card title="Lower ingestion cost" icon="dollar-sign">
Most managed log backends bill per event. A Python traceback in text format is counted as one event per line — 30+ events for a single error. JSON output collapses each traceback into a single event with the stack trace as an escaped string field.
</Card>
<Card title="Structured search" icon="magnifying-glass">
Search by `@automation_id`, `@exception.type`, `@kickoff_id` instead of grepping free-text. Build dashboards on typed facets without parser configuration.
</Card>
<Card title="APM ↔ logs correlation" icon="link">
Every event carries `trace_id` and `span_id` when fired inside a recording span, so backends auto-link logs to traces.
</Card>
<Card title="Stable contract" icon="file-shield">
The `schema` field gates compatibility — within `v1`, fields are added but never renamed or removed.
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:23.482914Z",
"level": "INFO",
"logger": "crewai_enterprise.utilities.pii_redaction",
"crewai_version": "1.14.7",
"msg": "PII tracking state reset (engines preserved)",
"automation_id": "12",
"task_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow"
}
```
An error with a Python exception is collapsed into a single event with the traceback as a string:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:31.218450Z",
"level": "ERROR",
"logger": "api.tasks.flow_run_task",
"crewai_version": "1.14.7",
"msg": "Flow execution failed",
"automation_id": "12",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow",
"exception": {
"type": "ValueError",
"message": "Topic cannot be empty",
"stacktrace": "Traceback (most recent call last):\n File \"/app/flow.py\", line 42, in summarize\n ...\nValueError: Topic cannot be empty\n"
}
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
Within the `v1` schema, fields are only added, never renamed or removed. New fields will appear as soon as a deployment is upgraded.
| Field | Type | Always present | Source |
|-------|------|----------------|--------|
| `schema` | string | Yes | Constant `"v1"`. Increment indicates a breaking schema change. |
| `ts` | string (ISO-8601 UTC, microseconds) | Yes | Record creation time, e.g. `2026-06-17T16:14:23.482914Z`. |
| `level` | string | Yes | Python log level name: `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. |
| `logger` | string | Yes | Dotted logger name, e.g. `api.tasks.flow_run_task`. |
| `crewai_version` | string | Yes (when `crewai` package metadata is resolvable) | Installed `crewai` package version, e.g. `"1.14.7"`. |
| `msg` | string | Yes | Rendered log message (after `%`-formatting / `{}`-formatting). |
| `automation_id` | string | When `CREWAI_PLUS_ID` env var is set | Numeric deployment ID (AMP provisions this on every container). |
| `task_id` | string | On Celery worker logs | Celery task UUID, or `"no-task"` for non-task contexts. |
| `kickoff_id` | string | Inside an automation kickoff | UUID of the current kickoff. |
| `execution_id` | string | Inside an automation kickoff | UUID of the current sub-execution. Equal to `kickoff_id` at the top level; differs for nested flow methods that spawn sub-executions. |
| `automation_name` | string | Inside an automation kickoff | Human-readable automation/flow name, e.g. `"research_flow"`. |
| `trace_id` | string (32-hex) | Inside a recording OpenTelemetry span | Hex trace ID. Omitted when no span is active. |
| `span_id` | string (16-hex) | Inside a recording OpenTelemetry span | Hex span ID. Omitted when no span is active. |
| `exception` | object | When the log record has `exc_info` | `{type, message, stacktrace}` — full traceback as a single escaped string. |
<Tip>
Any additional `extra={...}` kwargs passed to a logger call appear as top-level JSON fields verbatim. Reserved field names above always win to keep the schema stable.
</Tip>
### Stability promise
The `schema` field declares the contract. Within `v1`, CrewAI commits to:
- **Never removing a field** that customers may have built queries or dashboards against.
- **Never renaming a field** in place — renames happen via a schema bump (e.g. `v2`), with the old name kept as a deprecated alias for at least one release cycle.
- **Adding new fields** at any time. Consumers should ignore unknown top-level keys.
When a `v2` is introduced, both the `schema` field and the migration guide will be published in advance, and `v1` will continue to be emitted for one release cycle so dashboards and queries have time to migrate.
## Prerequisite: promote facets
Datadog auto-discovers fields the first time it sees them but doesn't make them queryable in widgets until they're promoted to **facets**. This is a one-time setup in your Datadog account.
<Steps>
<Step title="Search for a CrewAI log">
Open [Logs Explorer](https://app.datadoghq.com/logs) and search `service:crewai*`. You should see at least one log event.
</Step>
<Step title="Promote each field">
Click any log entry to open the right-hand details panel. For each field below, hover the field name → click the gear icon → **Create facet**.
- `automation_id`, `automation_name`, `execution_id`, `kickoff_id`, `task_id`
- `crewai_version`, `model_id`
- `exception.type`, `exception.message`
Skip any field that already shows a star icon next to its name — that means it's already a facet. The `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.request.model` facets are typically promoted automatically by Datadog's LLM Observability auto-discovery, but verify they exist before importing the dashboard.
</Step>
</Steps>
## Import the dashboard
<Steps>
<Step title="Download the dashboard JSON">
Save [`datadog_dashboard.json`](https://raw.githubusercontent.com/crewAIInc/crewAI/main/docs/edge/en/enterprise/guides/datadog_dashboard.json) to your machine.
</Step>
<Step title="Open the import dialog in Datadog">
Navigate to **Dashboards → New Dashboard**. Click the **gear icon** in the top right of the empty dashboard and select **Import Dashboard JSON**.
</Step>
<Step title="Paste or upload the JSON">
Paste the contents of `datadog_dashboard.json` into the import dialog (or drag the file in). Click **Import**.
Datadog creates the dashboard immediately and lands you on it. The first load may show empty widgets for a few seconds while queries execute against the time range.
</Step>
</Steps>
<Tip>
Datadog's [Dashboard API](https://docs.datadoghq.com/api/latest/dashboards/#create-a-new-dashboard) accepts the same JSON via `POST /api/v1/dashboard`. Use it if you manage dashboards through Terraform, Pulumi, or CI.
</Tip>
## What you get
The dashboard is organized into four sections plus a placeholder for a custom drill-down widget:
| Section | Widgets | Useful for |
|---------|---------|------------|
| **Header** | Total Executions · Error Rate (%) · Active Automations · CrewAI Versions in Use | At-a-glance health for the last hour. Error Rate is conditionally formatted (green ≤ 5%, yellow ≤ 10%, red > 10%). |
| **Throughput** | Executions per Hour by Automation (top 10, stacked bars) | Spotting traffic shifts, surfacing busy automations, validating that a rollout didn't change baseline volume. |
| **Errors** | Errors by Exception Type (top 5, stacked bars) · Top Exception Types by Count (toplist) | Triaging failures — which exception types are spiking, which automations they're hitting. |
| **Cost** | Total Tokens per Hour by Model (input + output, stacked area) | Tracking LLM token spend by model. Useful for catching cost regressions when an automation switches model or starts looping. |
| **Drill-Down** | _(empty placeholder)_ | See [Customization](#customize) for adding a recent-errors log stream here. |
Three template variables at the top of the dashboard re-scope every widget at once:
- **`$automation`** — filter to a single automation by name.
- **`$version`** — filter to a single `crewai` SDK version (useful for comparing pre- and post-upgrade behavior).
- **`$service`** — filter to a specific Datadog `service` tag (useful when multiple CrewAI deployments share one Datadog account).
## Verify ingestion
Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matches your ingestion path:
<Tabs>
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
If nothing appears, verify the collector endpoint is correct (`/v1/logs` for logs, `/v1/traces` for traces) and **Test Connection** succeeded when the collector was saved.
</Tab>
</Tabs>
## Customize
The dashboard ships with deliberate gaps so you can extend it without uninstalling and re-importing.
### Add a Recent Errors log stream
The **Drill-Down** section is intentionally empty. Add a Log Stream widget to it for an inline view of recent failures:
1. Edit the dashboard and click **+ Add Widgets** inside the Drill-Down group.
2. Drag in a **Log Stream** widget.
3. Set the filter query to `status:error $automation $version $service`.
4. Choose columns: `@timestamp`, `@automation_name`, `@exception.type`, `@exception.message`, `@execution_id`.
5. Sort by most recent, limit to 25 entries.
Clicking any row jumps to Logs Explorer with the same filter pre-applied.
### Add p95 latency
Logs don't include execution duration by default. Two ways to add a latency widget:
- **From APM traces** — if you also export OTLP traces to Datadog, add a Timeseries widget with data source **Traces**, query `service:crewai*`, aggregation `p95 of @duration`. Datadog APM auto-tracks span duration.
- **From metric extraction** — extract a `flow.duration_ms` metric from logs via [Datadog's log-to-metric pipeline](https://docs.datadoghq.com/logs/log_configuration/logs_to_metrics/), then chart it like any other metric. Useful if you don't run APM.
### Re-scope to multiple deployments
The `$service` template variable defaults to `*` and will catch every CrewAI deployment in your Datadog account. Change the default to a specific service name in **Configure → Template Variables** if you want the dashboard to focus on one deployment by default.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps
<CardGroup cols={2}>
<Card title="OpenTelemetry Export" icon="magnifying-glass-chart" href="./capture_telemetry_logs">
Vendor-neutral observability for non-Datadog stacks (Grafana, Honeycomb, your own collector) — or as a Datadog complement when you want to fan out telemetry to multiple backends.
</Card>
<Card title="Datadog Log Search Syntax" icon="magnifying-glass" href="https://docs.datadoghq.com/logs/explorer/search_syntax/">
Reference for customizing widget queries against the structured facets above.
</Card>
</CardGroup>

View File

@@ -4,6 +4,86 @@ description: "Product updates, improvements, and bug fixes for CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="Jun 18, 2026">
## v1.14.8a2
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a2)
## What's Changed
### Features
- Add single agent action to Flow definitions
- Validate flow CEL expressions at definition load time
### Documentation
- Add Datadog integration guide with importable operations dashboard
- Update snapshot and changelog for v1.14.8a1
## Contributors
@joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="Jun 18, 2026">
## v1.14.8a1
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## What's Changed
### Features
- Add optional if expression to each.do steps
### Bug Fixes
- Fix JSON crew issues
### Documentation
- Update snapshot and changelog for v1.14.8a
## Contributors
@joaomdmoura, @vinibrsl
</Update>
<Update label="Jun 17, 2026">
## v1.14.8a
[View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## What's Changed
### Features
- Add script/code block action to FlowDefinition
- Add crew actions to FlowDefinition
- Add `each` composite action to FlowDefinition
- Implement DMN mode support in crew creation and execution
- Enhance memory reset functionality and JSON crew handling
- Add expressions to FlowDefinition actions
- Implement Flow definition run tools without Python code
- Drive human feedback from the flow definition
- Wire config and persistence from FlowDefinition into the runtime
- Add experimental `crewai run --definition` for flows
- Support ZIP deployment fallback and JSON crew project env runs
- Introduce JSON first crews
### Bug Fixes
- Fix duplicated Exa tool
- Fix aggregate token usage across all LLM calls
- Resolve issues with crew loading and validation logic
### Documentation
- Document FlowDefinition fields in the JSON schema
- Update installation and quickstart documentation for JSON-first crew projects
- Update changelog and version for v1.14.7
## Contributors
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="Jun 11, 2026">
## v1.14.7

View File

@@ -39,6 +39,7 @@ The Enterprise Tools Repository includes:
- **Error Handling**: Incorporates robust error handling mechanisms to ensure smooth operation.
- **Caching Mechanism**: Features intelligent caching to optimize performance and reduce redundant operations.
- **Asynchronous Support**: Handles both synchronous and asynchronous tools, enabling non-blocking operations.
- **Typed Outputs**: Uses optional Pydantic models to give agents clear JSON fields while direct Python calls still receive the tool's normal return value.
## Using CrewAI Tools
@@ -184,6 +185,55 @@ class MyCustomTool(BaseTool):
return "Tool's result"
```
### Typed Tool Outputs
When a tool returns structured data, define a Pydantic output model. This gives the agent field names it can trust, such as `sku`, `quantity`, or `needs_reorder`.
Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent a JSON string based on the output model.
```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel
class InventoryResult(BaseModel):
sku: str
quantity: int
needs_reorder: bool
class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Checks current stock for a product SKU."
def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
tool = InventoryTool()
# Direct calls receive the raw Pydantic object.
result = tool.run(sku="SKU-123")
print(result.quantity)
```
To send Markdown or another short text format to the agent, override `format_output_for_agent`. Direct calls to `tool.run(...)` still return the normal Python value.
```python Code
class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Checks current stock for a product SKU."
def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
def format_output_for_agent(self, raw_result: object) -> str:
result = InventoryResult.model_validate(raw_result)
status = "reorder needed" if result.needs_reorder else "stock is healthy"
return f"{result.sku}: {result.quantity} units. {status}."
```
If you do not override `format_output_for_agent`, typed outputs are sent to the agent as JSON. Plain string results work as before.
## Asynchronous Tool Support
CrewAI supports asynchronous tools, allowing you to implement tools that perform non-blocking operations like network requests, file I/O, or other async operations without blocking the main execution thread.

View File

@@ -0,0 +1,87 @@
---
title: One Card per Step
description: "Each step on the Studio canvas is a single card that combines the task and the agent that performs it."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
</Note>
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:
- **The task** — what to do (name, description, expected output, and response format).
- **The agent** — who does it (the assigned agent, its model, and its tools).
An agent isn't an independent participant in your workflow — it's an attribute of the task: *which agent performs this work.* Putting the task and its agent on one card makes that relationship explicit and turns your automation into a single, left-to-right chain of work units that's easier to read at a glance.
<Frame caption="One card per step: the task with its assigned agent summarized in the footer.">
![Merged step cards on the canvas](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## On the canvas
Each collapsed card shows:
- The **task name and description** at the top.
- A **footer summarizing the assigned agent** — avatar, name, model, and tools.
There's no separate agent node and no vertical agent → task edge. Your steps connect directly to one another in the order they run.
## In the editor
Open a card to edit it. The expanded view is the same card in a detailed state — not a different screen — organized into two clearly labeled sections.
<Frame caption="The expanded editor: the task section open, the agent summarized below it.">
![Expanded step editor](/images/enterprise/merged-step-card-editor.png)
</Frame>
### The task — what to do
Open by default, since this is what you usually edit:
- **Name**
- **Description**
- **Expected Output**
- **Response Format** — surfaced here because it controls exactly what downstream steps (such as routing) read from this step.
### The agent — who does it
The assigned agent is shown as a summary — **name, model, and tools inline**. Its deeper configuration is preserved behind two disclosures:
- **Role, goal & backstory**
- **Agent settings** — reasoning, max reasoning attempts, allow delegation, max iterations, and LLM settings.
<Tip>
An agent's full configuration — Role, Goal, Backstory, Model, Tools, LLM Settings, and the complete Agent Settings block — lives behind the **Role, goal & backstory** and **Agent settings** disclosures, organized by how often you edit it.
</Tip>
## Swapping vs. editing the agent
There are two distinct ways to work with the agent on a card, and they do different things:
- **Swap** reassigns *which* agent performs this task. Use the **Swap** control to pick a different agent from this project, choose one from your Agent Repository, or create a new agent. This is scoped to the task.
- **Editing** the agent — opening **Role, goal & backstory** or **Agent settings** — changes the agent *itself*.
<Frame caption="Swap changes which agent performs the task.">
![Swap agent panel](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**Agents are reusable and shared.** The same agent can perform more than one task across your project. Editing an agent's role, backstory, or settings updates that agent **everywhere it's used** — not just on the card you opened. If you want a change to apply to only one step, **Swap** in a different agent instead of editing the shared one.
</Warning>
## Related
<CardGroup cols={2}>
<Card title="Crew Studio" href="/en/enterprise/features/crew-studio" icon="pencil">
Build automations with AI assistance and a visual editor.
</Card>
<Card title="Agent Repositories" href="/en/enterprise/features/agent-repositories" icon="users">
Manage and reuse agents across your automations.
</Card>
</CardGroup>

View File

@@ -9,6 +9,10 @@ CrewAI AMP can export OpenTelemetry **traces** and **logs** from your deployment
Telemetry data follows the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus additional CrewAI-specific attributes.
<Tip>
OpenTelemetry is the **recommended observability path** — vendor-neutral, works with any OTLP-compatible backend (Grafana, Honeycomb, NewRelic, your own collector). If you specifically use Datadog, see the dedicated [Datadog Integration](./datadog) guide which covers both the Datadog Agent path and Datadog's OTLP intake.
</Tip>
## Prerequisites
<CardGroup cols={2}>
@@ -41,17 +45,7 @@ Telemetry data follows the [OpenTelemetry GenAI semantic conventions](https://op
<Frame>![OpenTelemetry collector configuration](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Your Datadog site's OTLP host only, with no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
The Datadog integration exports **traces**.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
For Datadog setup, see the dedicated [Datadog Integration](./datadog) guide — it covers both the Datadog Agent path (recommended, cheaper for log volume) and Datadog's OTLP intake with full collector configuration steps.
</Tab>
</Tabs>

View File

@@ -0,0 +1,291 @@
---
title: "Datadog Integration"
description: "Monitor self-hosted CrewAI AMP deployments in Datadog via the Datadog Agent or Datadog's OTLP intake — either path lands the same structured facets so you can import the ready-made operations dashboard."
icon: "dog"
mode: "wide"
---
CrewAI ships first-class support for Datadog: two log-ingestion paths, a JSON log schema designed for cheap indexing, and a ready-made operations dashboard you can import in under five minutes.
<Note>
For vendor-neutral observability via any OTLP backend (Grafana, Honeycomb, your own collector), see [OpenTelemetry Export](./capture_telemetry_logs).
</Note>
## Choose a path
CrewAI supports two log-ingestion paths to Datadog — both are first-class and produce the same structured facets that power the dashboard. Pick the one that fits your infrastructure.
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
<Tab title="Datadog OTLP intake">
CrewAI AMP exports OpenTelemetry traffic directly to Datadog's OTLP endpoint with no Agent required. Logs and traces ride a single export pipeline configured in AMP's UI, using the same protocol you'd use for any other OTLP backend.
**Setup:**
1. In CrewAI AMP, go to **Settings → OpenTelemetry Collectors → Add Collector** and pick **Datadog**.
2. Configure the connection:
- **Datadog Site Domain** — your Datadog site's OTLP host only, no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
3. The Datadog template provisions **both signals at once** — when you save, AMP creates a traces collector at `/v1/traces` and a logs collector at `/v1/logs`, both sharing the same Datadog OTLP host and API key. You'll see them as two separate rows in your OTel collectors list.
4. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided. Then click **Save** — both collectors are created in one step.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
**Pick this path if** you'd rather not operate a Datadog Agent, you already use OTLP for traces and want one export pipeline, or you may later want to fan out the same telemetry to other backends (Grafana, Honeycomb, etc.) without changing your application setup.
</Tab>
</Tabs>
Either path lands the same structured facets in Datadog (`@automation_id`, `@kickoff_id`, `@execution_id`, `@automation_name`, `@crewai_version`, `@exception.type`, `@gen_ai.*`), so the dashboard works identically with either choice.
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
<CardGroup cols={2}>
<Card title="Lower ingestion cost" icon="dollar-sign">
Most managed log backends bill per event. A Python traceback in text format is counted as one event per line — 30+ events for a single error. JSON output collapses each traceback into a single event with the stack trace as an escaped string field.
</Card>
<Card title="Structured search" icon="magnifying-glass">
Search by `@automation_id`, `@exception.type`, `@kickoff_id` instead of grepping free-text. Build dashboards on typed facets without parser configuration.
</Card>
<Card title="APM ↔ logs correlation" icon="link">
Every event carries `trace_id` and `span_id` when fired inside a recording span, so backends auto-link logs to traces.
</Card>
<Card title="Stable contract" icon="file-shield">
The `schema` field gates compatibility — within `v1`, fields are added but never renamed or removed.
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:23.482914Z",
"level": "INFO",
"logger": "crewai_enterprise.utilities.pii_redaction",
"crewai_version": "1.14.7",
"msg": "PII tracking state reset (engines preserved)",
"automation_id": "12",
"task_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow"
}
```
An error with a Python exception is collapsed into a single event with the traceback as a string:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:31.218450Z",
"level": "ERROR",
"logger": "api.tasks.flow_run_task",
"crewai_version": "1.14.7",
"msg": "Flow execution failed",
"automation_id": "12",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow",
"exception": {
"type": "ValueError",
"message": "Topic cannot be empty",
"stacktrace": "Traceback (most recent call last):\n File \"/app/flow.py\", line 42, in summarize\n ...\nValueError: Topic cannot be empty\n"
}
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
Within the `v1` schema, fields are only added, never renamed or removed. New fields will appear as soon as a deployment is upgraded.
| Field | Type | Always present | Source |
|-------|------|----------------|--------|
| `schema` | string | Yes | Constant `"v1"`. Increment indicates a breaking schema change. |
| `ts` | string (ISO-8601 UTC, microseconds) | Yes | Record creation time, e.g. `2026-06-17T16:14:23.482914Z`. |
| `level` | string | Yes | Python log level name: `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. |
| `logger` | string | Yes | Dotted logger name, e.g. `api.tasks.flow_run_task`. |
| `crewai_version` | string | Yes (when `crewai` package metadata is resolvable) | Installed `crewai` package version, e.g. `"1.14.7"`. |
| `msg` | string | Yes | Rendered log message (after `%`-formatting / `{}`-formatting). |
| `automation_id` | string | When `CREWAI_PLUS_ID` env var is set | Numeric deployment ID (AMP provisions this on every container). |
| `task_id` | string | On Celery worker logs | Celery task UUID, or `"no-task"` for non-task contexts. |
| `kickoff_id` | string | Inside an automation kickoff | UUID of the current kickoff. |
| `execution_id` | string | Inside an automation kickoff | UUID of the current sub-execution. Equal to `kickoff_id` at the top level; differs for nested flow methods that spawn sub-executions. |
| `automation_name` | string | Inside an automation kickoff | Human-readable automation/flow name, e.g. `"research_flow"`. |
| `trace_id` | string (32-hex) | Inside a recording OpenTelemetry span | Hex trace ID. Omitted when no span is active. |
| `span_id` | string (16-hex) | Inside a recording OpenTelemetry span | Hex span ID. Omitted when no span is active. |
| `exception` | object | When the log record has `exc_info` | `{type, message, stacktrace}` — full traceback as a single escaped string. |
<Tip>
Any additional `extra={...}` kwargs passed to a logger call appear as top-level JSON fields verbatim. Reserved field names above always win to keep the schema stable.
</Tip>
### Stability promise
The `schema` field declares the contract. Within `v1`, CrewAI commits to:
- **Never removing a field** that customers may have built queries or dashboards against.
- **Never renaming a field** in place — renames happen via a schema bump (e.g. `v2`), with the old name kept as a deprecated alias for at least one release cycle.
- **Adding new fields** at any time. Consumers should ignore unknown top-level keys.
When a `v2` is introduced, both the `schema` field and the migration guide will be published in advance, and `v1` will continue to be emitted for one release cycle so dashboards and queries have time to migrate.
## Prerequisite: promote facets
Datadog auto-discovers fields the first time it sees them but doesn't make them queryable in widgets until they're promoted to **facets**. This is a one-time setup in your Datadog account.
<Steps>
<Step title="Search for a CrewAI log">
Open [Logs Explorer](https://app.datadoghq.com/logs) and search `service:crewai*`. You should see at least one log event.
</Step>
<Step title="Promote each field">
Click any log entry to open the right-hand details panel. For each field below, hover the field name → click the gear icon → **Create facet**.
- `automation_id`, `automation_name`, `execution_id`, `kickoff_id`, `task_id`
- `crewai_version`, `model_id`
- `exception.type`, `exception.message`
Skip any field that already shows a star icon next to its name — that means it's already a facet. The `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.request.model` facets are typically promoted automatically by Datadog's LLM Observability auto-discovery, but verify they exist before importing the dashboard.
</Step>
</Steps>
## Import the dashboard
<Steps>
<Step title="Download the dashboard JSON">
Save [`datadog_dashboard.json`](https://raw.githubusercontent.com/crewAIInc/crewAI/main/docs/edge/en/enterprise/guides/datadog_dashboard.json) to your machine.
</Step>
<Step title="Open the import dialog in Datadog">
Navigate to **Dashboards → New Dashboard**. Click the **gear icon** in the top right of the empty dashboard and select **Import Dashboard JSON**.
</Step>
<Step title="Paste or upload the JSON">
Paste the contents of `datadog_dashboard.json` into the import dialog (or drag the file in). Click **Import**.
Datadog creates the dashboard immediately and lands you on it. The first load may show empty widgets for a few seconds while queries execute against the time range.
</Step>
</Steps>
<Tip>
Datadog's [Dashboard API](https://docs.datadoghq.com/api/latest/dashboards/#create-a-new-dashboard) accepts the same JSON via `POST /api/v1/dashboard`. Use it if you manage dashboards through Terraform, Pulumi, or CI.
</Tip>
## What you get
The dashboard is organized into four sections plus a placeholder for a custom drill-down widget:
| Section | Widgets | Useful for |
|---------|---------|------------|
| **Header** | Total Executions · Error Rate (%) · Active Automations · CrewAI Versions in Use | At-a-glance health for the last hour. Error Rate is conditionally formatted (green ≤ 5%, yellow ≤ 10%, red > 10%). |
| **Throughput** | Executions per Hour by Automation (top 10, stacked bars) | Spotting traffic shifts, surfacing busy automations, validating that a rollout didn't change baseline volume. |
| **Errors** | Errors by Exception Type (top 5, stacked bars) · Top Exception Types by Count (toplist) | Triaging failures — which exception types are spiking, which automations they're hitting. |
| **Cost** | Total Tokens per Hour by Model (input + output, stacked area) | Tracking LLM token spend by model. Useful for catching cost regressions when an automation switches model or starts looping. |
| **Drill-Down** | _(empty placeholder)_ | See [Customization](#customize) for adding a recent-errors log stream here. |
Three template variables at the top of the dashboard re-scope every widget at once:
- **`$automation`** — filter to a single automation by name.
- **`$version`** — filter to a single `crewai` SDK version (useful for comparing pre- and post-upgrade behavior).
- **`$service`** — filter to a specific Datadog `service` tag (useful when multiple CrewAI deployments share one Datadog account).
## Verify ingestion
Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matches your ingestion path:
<Tabs>
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
If nothing appears, verify the collector endpoint is correct (`/v1/logs` for logs, `/v1/traces` for traces) and **Test Connection** succeeded when the collector was saved.
</Tab>
</Tabs>
## Customize
The dashboard ships with deliberate gaps so you can extend it without uninstalling and re-importing.
### Add a Recent Errors log stream
The **Drill-Down** section is intentionally empty. Add a Log Stream widget to it for an inline view of recent failures:
1. Edit the dashboard and click **+ Add Widgets** inside the Drill-Down group.
2. Drag in a **Log Stream** widget.
3. Set the filter query to `status:error $automation $version $service`.
4. Choose columns: `@timestamp`, `@automation_name`, `@exception.type`, `@exception.message`, `@execution_id`.
5. Sort by most recent, limit to 25 entries.
Clicking any row jumps to Logs Explorer with the same filter pre-applied.
### Add p95 latency
Logs don't include execution duration by default. Two ways to add a latency widget:
- **From APM traces** — if you also export OTLP traces to Datadog, add a Timeseries widget with data source **Traces**, query `service:crewai*`, aggregation `p95 of @duration`. Datadog APM auto-tracks span duration.
- **From metric extraction** — extract a `flow.duration_ms` metric from logs via [Datadog's log-to-metric pipeline](https://docs.datadoghq.com/logs/log_configuration/logs_to_metrics/), then chart it like any other metric. Useful if you don't run APM.
### Re-scope to multiple deployments
The `$service` template variable defaults to `*` and will catch every CrewAI deployment in your Datadog account. Change the default to a specific service name in **Configure → Template Variables** if you want the dashboard to focus on one deployment by default.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps
<CardGroup cols={2}>
<Card title="OpenTelemetry Export" icon="magnifying-glass-chart" href="./capture_telemetry_logs">
Vendor-neutral observability for non-Datadog stacks (Grafana, Honeycomb, your own collector) — or as a Datadog complement when you want to fan out telemetry to multiple backends.
</Card>
<Card title="Datadog Log Search Syntax" icon="magnifying-glass" href="https://docs.datadoghq.com/logs/explorer/search_syntax/">
Reference for customizing widget queries against the structured facets above.
</Card>
</CardGroup>

View File

@@ -0,0 +1,582 @@
{
"title": "crewAI -- Operations",
"description": "Monitoring dashboard for self-hosted crewAI deployments running structured JSON logs. Tracks executions, errors, token usage, and automation health.",
"widgets": [
{
"id": 8810001,
"definition": {
"title": "Header",
"background_color": "vivid_blue",
"show_title": true,
"type": "group",
"layout_type": "ordered",
"widgets": [
{
"id": 9910001,
"definition": {
"title": "Total Executions",
"time": {
"live_span": "1h"
},
"type": "query_value",
"requests": [
{
"response_format": "scalar",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "cardinality",
"metric": "@execution_id"
},
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
]
}
],
"autoscale": true,
"precision": 0
},
"layout": {
"x": 0,
"y": 0,
"width": 3,
"height": 2
}
},
{
"id": 9910002,
"definition": {
"title": "Error Rate (%)",
"time": {
"live_span": "1h"
},
"type": "query_value",
"requests": [
{
"response_format": "scalar",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "status:error $automation $version $service"
},
"compute": {
"aggregation": "count"
},
"indexes": [
"*"
]
},
{
"data_source": "logs",
"name": "query2",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "cardinality",
"metric": "@execution_id"
},
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1 / query2 * 100"
}
],
"conditional_formats": [
{
"comparator": ">",
"value": 10,
"palette": "white_on_red"
},
{
"comparator": ">",
"value": 5,
"palette": "white_on_yellow"
},
{
"comparator": ">=",
"value": 0,
"palette": "white_on_green"
}
]
}
],
"autoscale": false,
"custom_unit": "%",
"precision": 2
},
"layout": {
"x": 3,
"y": 0,
"width": 3,
"height": 2
}
},
{
"id": 9910003,
"definition": {
"title": "Active Automations",
"time": {
"live_span": "1h"
},
"type": "query_value",
"requests": [
{
"response_format": "scalar",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "cardinality",
"metric": "@automation_id"
},
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
]
}
],
"autoscale": true,
"precision": 0
},
"layout": {
"x": 6,
"y": 0,
"width": 3,
"height": 2
}
},
{
"id": 9910004,
"definition": {
"title": "CrewAI Versions in Use",
"time": {
"live_span": "1h"
},
"type": "query_value",
"requests": [
{
"response_format": "scalar",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "cardinality",
"metric": "@crewai_version"
},
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
]
}
],
"autoscale": true,
"precision": 0
},
"layout": {
"x": 9,
"y": 0,
"width": 3,
"height": 2
}
}
]
},
"layout": {
"x": 0,
"y": 0,
"width": 12,
"height": 3
}
},
{
"id": 8820001,
"definition": {
"title": "Throughput",
"background_color": "vivid_green",
"show_title": true,
"type": "group",
"layout_type": "ordered",
"widgets": [
{
"id": 9920001,
"definition": {
"title": "Executions per Hour by Automation (top 10)",
"show_legend": false,
"type": "timeseries",
"requests": [
{
"response_format": "timeseries",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "cardinality",
"metric": "@execution_id",
"interval": 3600000
},
"group_by": [
{
"facet": "@automation_name",
"limit": 10,
"sort": {
"aggregation": "cardinality",
"metric": "@execution_id",
"order": "desc"
}
}
],
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
],
"style": {
"palette": "semantic"
},
"display_type": "bars"
}
]
},
"layout": {
"x": 0,
"y": 0,
"width": 12,
"height": 3
}
}
]
},
"layout": {
"x": 0,
"y": 3,
"width": 12,
"height": 4
}
},
{
"id": 8830001,
"definition": {
"title": "Errors",
"background_color": "vivid_orange",
"show_title": true,
"type": "group",
"layout_type": "ordered",
"widgets": [
{
"id": 9930001,
"definition": {
"title": "Errors by Exception Type (top 5)",
"show_legend": false,
"type": "timeseries",
"requests": [
{
"response_format": "timeseries",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "status:error $automation $version $service"
},
"compute": {
"aggregation": "count"
},
"group_by": [
{
"facet": "@exception.type",
"limit": 5,
"sort": {
"aggregation": "count",
"order": "desc"
}
}
],
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
],
"style": {
"palette": "warm"
},
"display_type": "bars"
}
]
},
"layout": {
"x": 0,
"y": 0,
"width": 6,
"height": 3
}
},
{
"id": 9930002,
"definition": {
"title": "Top Exception Types by Count",
"type": "toplist",
"requests": [
{
"response_format": "scalar",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "status:error $automation $version $service"
},
"compute": {
"aggregation": "count"
},
"group_by": [
{
"facet": "@exception.type",
"limit": 10,
"sort": {
"aggregation": "count",
"order": "desc"
}
}
],
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1"
}
],
"sort": {
"count": 10,
"order_by": [
{
"type": "formula",
"index": 0,
"order": "desc"
}
]
}
}
],
"style": {
"palette": "datadog16"
}
},
"layout": {
"x": 6,
"y": 0,
"width": 6,
"height": 3
}
}
]
},
"layout": {
"x": 0,
"y": 7,
"width": 12,
"height": 4
}
},
{
"id": 8840001,
"definition": {
"title": "Cost",
"background_color": "vivid_purple",
"show_title": true,
"type": "group",
"layout_type": "ordered",
"widgets": [
{
"id": 9940001,
"definition": {
"title": "Total Tokens per Hour by Model (input + output)",
"show_legend": false,
"type": "timeseries",
"requests": [
{
"response_format": "timeseries",
"queries": [
{
"data_source": "logs",
"name": "query1",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "sum",
"metric": "@gen_ai.usage.input_tokens",
"interval": 3600000
},
"group_by": [
{
"facet": "@gen_ai.request.model",
"limit": 10,
"sort": {
"aggregation": "sum",
"metric": "@gen_ai.usage.input_tokens",
"order": "desc"
}
}
],
"indexes": [
"*"
]
},
{
"data_source": "logs",
"name": "query2",
"search": {
"query": "$automation $version $service"
},
"compute": {
"aggregation": "sum",
"metric": "@gen_ai.usage.output_tokens",
"interval": 3600000
},
"group_by": [
{
"facet": "@gen_ai.request.model",
"limit": 10,
"sort": {
"aggregation": "sum",
"metric": "@gen_ai.usage.output_tokens",
"order": "desc"
}
}
],
"indexes": [
"*"
]
}
],
"formulas": [
{
"formula": "query1 + query2",
"alias": "Total Tokens"
}
],
"style": {
"palette": "cool"
},
"display_type": "area"
}
]
},
"layout": {
"x": 0,
"y": 0,
"width": 12,
"height": 3
}
}
]
},
"layout": {
"x": 0,
"y": 11,
"width": 12,
"height": 4
}
},
{
"id": 8850002,
"definition": {
"title": "Drill-Down",
"background_color": "gray",
"show_title": true,
"type": "group",
"layout_type": "ordered",
"widgets": []
},
"layout": {
"x": 0,
"y": 15,
"width": 12,
"height": 1
}
}
],
"template_variables": [
{
"name": "automation",
"prefix": "@automation_name",
"available_values": [],
"default": "*"
},
{
"name": "version",
"prefix": "@crewai_version",
"available_values": [],
"default": "*"
},
{
"name": "service",
"prefix": "service",
"available_values": [],
"default": "*"
}
],
"layout_type": "ordered",
"notify_list": [],
"pause_auto_refresh": false,
"reflow_type": "fixed",
"tags": [
"ai:created_with_ai"
]
}

View File

@@ -65,7 +65,7 @@ Regardless of which approach you use, your tool must:
- Have a **`description`** — tells the agent when and how to use the tool. This directly affects how well agents use your tool, so be clear and specific.
- Implement **`_run`** (BaseTool) or provide a **function body** (@tool) — the synchronous execution logic.
- Use **type annotations** on all parameters and return values.
- Return a **string** result (or something that can be meaningfully converted to one).
- Return a **string** result, or define an optional Pydantic output schema for structured results.
### Optional: Async Support
@@ -104,6 +104,67 @@ class TranslateInput(BaseModel):
Explicit schemas are recommended for published tools — they produce better agent behavior and clearer documentation for your users.
### Optional: Typed Outputs with `result_schema`
If your tool returns structured data, define a Pydantic output model. This is a good default for published tools because users and agents can rely on named fields.
Direct Python calls still receive the value your tool returns. When an agent uses the tool, CrewAI sends the agent JSON based on the output model.
CrewAI can infer the output schema from a Pydantic return annotation:
```python
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class GeolocateResult(BaseModel):
latitude: float = Field(..., description="Latitude in decimal degrees.")
longitude: float = Field(..., description="Longitude in decimal degrees.")
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
def _run(self, address: str) -> GeolocateResult:
if "1600 Pennsylvania" in address:
return GeolocateResult(latitude=38.8977, longitude=-77.0365)
return GeolocateResult(latitude=40.7128, longitude=-74.0060)
```
Set `result_schema` explicitly when your tool returns a dictionary:
```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
result_schema: type[BaseModel] = GeolocateResult
def _run(self, address: str) -> dict[str, float]:
if "1600 Pennsylvania" in address:
return {"latitude": 38.8977, "longitude": -77.0365}
return {"latitude": 40.7128, "longitude": -74.0060}
```
If agents should receive a short text summary instead of JSON, override `format_output_for_agent` on your `BaseTool` subclass.
```python
class GeolocateTool(BaseTool):
name: str = "Geolocate"
description: str = "Converts a street address into latitude/longitude coordinates."
def _run(self, address: str) -> GeolocateResult:
if "1600 Pennsylvania" in address:
return GeolocateResult(latitude=38.8977, longitude=-77.0365)
return GeolocateResult(latitude=40.7128, longitude=-74.0060)
def format_output_for_agent(self, raw_result: object) -> str:
result = GeolocateResult.model_validate(raw_result)
return f"Latitude {result.latitude}, longitude {result.longitude}"
```
The override only changes what the agent sees. Direct users of your package still receive the normal value from `tool.run(...)`.
### Optional: Environment Variables
If your tool requires API keys or other configuration, declare them with `env_vars` so users know what to set:
@@ -241,4 +302,4 @@ agent = Agent(
tools=[GeolocateTool()],
# ...
)
```
```

View File

@@ -28,6 +28,60 @@ mode: "wide"
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
<a className="button button-primary" href="/en/quickstart">Get started</a>
<button
type="button"
className="button"
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
npx skills add crewaiinc/skills
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
Use these CrewAI docs as source of truth before making assumptions:
- https://skills.crewai.com
- https://docs.crewai.com/llms.txt
- https://docs.crewai.com/en/installation
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
Setup steps:
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
2. Install uv if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh
3. Source the uv environment if needed:
source "$HOME/.local/bin/env"
4. Install the CrewAI CLI:
uv tool install crewai
5. Verify the CLI:
crewai version
crewai create --help
6. Create a project:
CREWAI_DMN=true crewai create
7. After project creation, inspect the generated files before editing.
8. Run:
crewai install
crewai run
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
window.setTimeout(() => {
button.textContent = "Copy instructions for coding agents";
}, 1600);
}
}}
>
Copy instructions for coding agents
</button>
<a className="button" href="/en/changelog">View changelog</a>
<a className="button" href="/en/api-reference/introduction">API Reference</a>
</div>

View File

@@ -9,7 +9,60 @@ mode: "wide"
Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI.
You can install it with `npx skills add crewaiinc/skills`
<button
type="button"
className="button button-primary"
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
npx skills add crewaiinc/skills
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
Use these CrewAI docs as source of truth before making assumptions:
- https://skills.crewai.com
- https://docs.crewai.com/llms.txt
- https://docs.crewai.com/en/installation
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
Setup steps:
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
2. Install uv if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh
3. Source the uv environment if needed:
source "$HOME/.local/bin/env"
4. Install the CrewAI CLI:
uv tool install crewai
5. Verify the CLI:
crewai version
crewai create --help
6. Create a project:
CREWAI_DMN=true crewai create
7. After project creation, inspect the generated files before editing.
8. Run:
crewai install
crewai run
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
window.setTimeout(() => {
button.textContent = "Copy instructions for coding agents";
}, 1600);
}
}}
>
Copy instructions for coding agents
</button>
<iframe src="https://www.loom.com/embed/befb9f68b81f42ad8112bfdd95a780af" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style={{width: "100%", height: "400px"}}></iframe>

View File

@@ -53,6 +53,111 @@ def my_simple_tool(question: str) -> str:
return "Tool output"
```
### Best Practice: Define Typed Outputs
When a tool returns structured data, define a Pydantic output model. This helps the agent read the result as clear fields instead of guessing from plain text.
Typed outputs are useful for results with stable fields, such as IDs, status values, scores, prices, or lists. Plain strings are still fine for short prose results.
Direct Python calls still receive the value your tool returns. When an agent uses a typed tool, CrewAI sends the agent JSON based on the output model.
#### Return a Pydantic Model
CrewAI infers the output schema when your `BaseTool` has a Pydantic return annotation.
```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class InventoryResult(BaseModel):
sku: str = Field(description="The product SKU.")
quantity: int = Field(description="Units available.")
needs_reorder: bool = Field(description="Whether the item should be reordered.")
class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Check current stock for a product SKU."
def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
tool = InventoryTool()
result = tool.run(sku="SKU-123")
# Direct Python calls receive the raw Pydantic object.
print(result.quantity)
```
When an agent calls `InventoryTool`, it receives JSON like this:
```json
{"sku":"SKU-123","quantity":14,"needs_reorder":false}
```
#### Use `result_schema` with Dictionary Results
If your tool returns a dictionary, set `result_schema` explicitly. You can do this on a `BaseTool` subclass or with the `@tool` decorator:
```python Code
from crewai.tools import tool
from pydantic import BaseModel, Field
class ProductResult(BaseModel):
sku: str = Field(description="The product SKU.")
name: str = Field(description="The product name.")
in_stock: bool = Field(description="Whether the product is available.")
@tool("Product Lookup", result_schema=ProductResult)
def product_lookup(sku: str) -> dict[str, object]:
"""Look up product availability by SKU."""
catalog = {
"SKU-123": ("Noise-canceling headset", True),
"SKU-456": ("USB-C dock", False),
}
name, in_stock = catalog.get(sku, ("Unknown product", False))
return {
"sku": sku,
"name": name,
"in_stock": in_stock,
}
```
#### Customize the Text Sent to the Agent
By default, typed tool outputs are sent to the agent as JSON. If the agent should receive a short summary instead, subclass `BaseTool` and override `format_output_for_agent`.
```python Code
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
class InventoryResult(BaseModel):
sku: str = Field(description="The product SKU.")
quantity: int = Field(description="Units available.")
needs_reorder: bool = Field(description="Whether the item should be reordered.")
class InventoryTool(BaseTool):
name: str = "Inventory Check"
description: str = "Check current stock for a product SKU."
def _run(self, sku: str) -> InventoryResult:
quantity = {"SKU-123": 14, "SKU-456": 0}.get(sku, 0)
return InventoryResult(sku=sku, quantity=quantity, needs_reorder=quantity < 5)
def format_output_for_agent(self, raw_result: object) -> str:
result = InventoryResult.model_validate(raw_result)
status = "reorder needed" if result.needs_reorder else "stock is healthy"
return f"{result.sku}: {result.quantity} units. {status}."
tool = InventoryTool()
result = tool.run(sku="SKU-123")
# Direct Python calls receive the raw Pydantic object.
print(result.quantity)
```
The override only changes what the agent sees. Direct calls to `tool.run(...)` still return the normal Python value.
### Defining a Cache Function for the Tool
To optimize tool performance with caching, define custom caching strategies using the `cache_function` attribute.

View File

@@ -195,9 +195,12 @@ class ToolCallHookContext:
agent: Agent | None # Agent executing
task: Task | None # Current task
crew: Crew | None # Crew instance
tool_result: str | None # Tool result (after hooks)
tool_result: str | None # Agent-facing result string (after hooks)
raw_tool_result: Any | None # Raw Python result (after hooks)
```
For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. `raw_tool_result` is the original Python value returned by the tool.
## Common Patterns
### Safety and Validation

View File

@@ -60,9 +60,12 @@ class ToolCallHookContext:
agent: Agent | BaseAgent | None # Agent executing the tool
task: Task | None # Current task
crew: Crew | None # Crew instance
tool_result: str | None # Tool result (after hooks only)
tool_result: str | None # Agent-facing result string (after hooks only)
raw_tool_result: Any | None # Raw Python result (after hooks only)
```
For typed tool outputs, `tool_result` is the string the agent sees. By default, this is JSON. If the tool uses custom formatting, it can be Markdown or another string. Use `raw_tool_result` when your hook needs the typed object or dictionary.
### Modifying Tool Inputs
**Important:** Always modify tool inputs in-place:

View File

@@ -4,6 +4,86 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정"
icon: "clock"
mode: "wide"
---
<Update label="2026년 6월 18일">
## v1.14.8a2
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a2)
## 변경 사항
### 기능
- Flow 정의에 단일 에이전트 작업 추가
- 정의 로드 시 흐름 CEL 표현식 검증
### 문서
- 가져올 수 있는 운영 대시보드와 함께 Datadog 통합 가이드 추가
- v1.14.8a1의 스냅샷 및 변경 로그 업데이트
## 기여자
@joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="2026년 6월 18일">
## v1.14.8a1
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## 변경 사항
### 기능
- 각 do 단계에 선택적 if 표현식을 추가
### 버그 수정
- JSON 크루 문제 수정
### 문서
- v1.14.8a의 스냅샷 및 변경 로그 업데이트
## 기여자
@joaomdmoura, @vinibrsl
</Update>
<Update label="2026년 6월 17일">
## v1.14.8a
[GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## 변경 사항
### 기능
- FlowDefinition에 스크립트/코드 블록 액션 추가
- FlowDefinition에 크루 액션 추가
- FlowDefinition에 `each` 복합 액션 추가
- 크루 생성 및 실행에서 DMN 모드 지원 구현
- 메모리 재설정 기능 및 JSON 크루 처리 기능 향상
- FlowDefinition 액션에 표현식 추가
- Python 코드 없이 Flow 정의 실행 도구 구현
- Flow 정의에서 인간 피드백 유도
- FlowDefinition의 구성 및 지속성을 런타임에 연결
- 흐름을 위한 실험적 `crewai run --definition` 추가
- ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원
- JSON 우선 크루 도입
### 버그 수정
- 중복된 Exa 도구 수정
- 모든 LLM 호출에서 집계 토큰 사용 수정
- 크루 로딩 및 검증 로직 관련 문제 해결
### 문서
- JSON 스키마에서 FlowDefinition 필드 문서화
- JSON 우선 크루 프로젝트에 대한 설치 및 빠른 시작 문서 업데이트
- v1.14.7에 대한 변경 로그 및 버전 업데이트
## 기여자
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="2026년 6월 11일">
## v1.14.7

View File

@@ -0,0 +1,87 @@
---
title: 단계당 하나의 카드
description: "Studio 캔버스의 각 단계는 작업과 이를 수행하는 에이전트를 하나로 결합한 단일 카드입니다."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
</Note>
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
- **작업(Task)** — 무엇을 할지(이름, 설명, 예상 출력, 응답 형식).
- **에이전트(Agent)** — 누가 수행하는지(할당된 에이전트, 모델, 도구).
에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다.
<Frame caption="단계당 하나의 카드: 작업과 푸터에 요약된 할당 에이전트.">
![캔버스의 통합 단계 카드](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## 캔버스에서
접힌 각 카드는 다음을 표시합니다:
- 상단의 **작업 이름과 설명**.
- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구.
별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다.
## 에디터에서
카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다.
<Frame caption="확장된 에디터: 작업 섹션이 열려 있고 그 아래에 에이전트가 요약되어 있습니다.">
![확장된 단계 에디터](/images/enterprise/merged-step-card-editor.png)
</Frame>
### 작업 — 무엇을 할지
가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다:
- **이름**
- **설명**
- **예상 출력**
- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다.
### 에이전트 — 누가 수행하는지
할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다:
- **역할, 목표 및 배경 스토리**
- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정.
<Tip>
에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다.
</Tip>
## 에이전트 교체 vs. 편집
카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다:
- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다.
- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다.
<Frame caption="교체는 작업을 수행할 에이전트를 변경합니다.">
![에이전트 교체 패널](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요.
</Warning>
## 관련 항목
<CardGroup cols={2}>
<Card title="Crew Studio" href="/ko/enterprise/features/crew-studio" icon="pencil">
AI 지원과 비주얼 에디터로 자동화를 구축합니다.
</Card>
<Card title="에이전트 저장소" href="/ko/enterprise/features/agent-repositories" icon="users">
자동화 전반에서 에이전트를 관리하고 재사용합니다.
</Card>
</CardGroup>

View File

@@ -9,6 +9,10 @@ CrewAI AMP는 배포에서 OpenTelemetry **트레이스**와 **로그**를 자
텔레메트리 데이터는 [OpenTelemetry GenAI 시맨틱 규칙](https://opentelemetry.io/docs/specs/semconv/gen-ai/)과 추가적인 CrewAI 전용 속성을 따릅니다.
<Tip>
OpenTelemetry는 **권장되는 관측 가능성 경로**입니다 — 벤더 중립적이며, OTLP 호환 백엔드(Grafana, Honeycomb, NewRelic, 자체 수집기)에서 작동합니다. Datadog을 사용하는 경우, Datadog Agent 경로와 Datadog의 OTLP 수집을 모두 다루는 전용 [Datadog 통합](./datadog) 가이드를 참조하세요.
</Tip>
## 사전 요구 사항
<CardGroup cols={2}>
@@ -41,17 +45,7 @@ CrewAI AMP는 배포에서 OpenTelemetry **트레이스**와 **로그**를 자
<Frame>![OpenTelemetry 수집기 구성](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Datadog 사이트의 OTLP 호스트만 입력합니다 (프로토콜이나 경로 제외). CrewAI가 전체 HTTPS OTLP 엔드포인트를 자동으로 구성합니다. [Datadog 사이트](https://docs.datadoghq.com/getting_started/site/)에 맞는 호스트를 사용하세요:
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Datadog API 키입니다. [키 생성 방법](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys)을 참고하세요.
Datadog 통합은 **트레이스**를 내보냅니다.
<Frame>![Datadog 수집기 구성](/images/crewai-otel-collector-datadog.png)</Frame>
Datadog 설정은 전용 [Datadog 통합](./datadog) 가이드를 참조하세요 — Datadog Agent 경로(권장, 로그 볼륨에 더 저렴)와 Datadog의 OTLP 수집을 모두 다루며, 수집기 구성 단계를 완전히 설명합니다.
</Tab>
</Tabs>

View File

@@ -0,0 +1,295 @@
---
title: "Datadog 통합"
description: "Datadog Agent 또는 Datadog의 OTLP 수집을 통해 자체 호스팅 CrewAI AMP 배포를 Datadog에서 모니터링하세요 — 두 경로 모두 동일한 구조화된 패싯을 생성하므로 기성 운영 대시보드를 가져올 수 있습니다."
icon: "dog"
mode: "wide"
---
<Note>
**번역 진행 중** — 콘텐츠가 영어로 표시됩니다.
</Note>
CrewAI ships first-class support for Datadog: two log-ingestion paths, a JSON log schema designed for cheap indexing, and a ready-made operations dashboard you can import in under five minutes.
<Note>
For vendor-neutral observability via any OTLP backend (Grafana, Honeycomb, your own collector), see [OpenTelemetry Export](./capture_telemetry_logs).
</Note>
## Choose a path
CrewAI supports two log-ingestion paths to Datadog — both are first-class and produce the same structured facets that power the dashboard. Pick the one that fits your infrastructure.
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
<Tab title="Datadog OTLP intake">
CrewAI AMP exports OpenTelemetry traffic directly to Datadog's OTLP endpoint with no Agent required. Logs and traces ride a single export pipeline configured in AMP's UI, using the same protocol you'd use for any other OTLP backend.
**Setup:**
1. In CrewAI AMP, go to **Settings → OpenTelemetry Collectors → Add Collector** and pick **Datadog**.
2. Configure the connection:
- **Datadog Site Domain** — your Datadog site's OTLP host only, no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
3. The Datadog template provisions **both signals at once** — when you save, AMP creates a traces collector at `/v1/traces` and a logs collector at `/v1/logs`, both sharing the same Datadog OTLP host and API key. You'll see them as two separate rows in your OTel collectors list.
4. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided. Then click **Save** — both collectors are created in one step.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
**Pick this path if** you'd rather not operate a Datadog Agent, you already use OTLP for traces and want one export pipeline, or you may later want to fan out the same telemetry to other backends (Grafana, Honeycomb, etc.) without changing your application setup.
</Tab>
</Tabs>
Either path lands the same structured facets in Datadog (`@automation_id`, `@kickoff_id`, `@execution_id`, `@automation_name`, `@crewai_version`, `@exception.type`, `@gen_ai.*`), so the dashboard works identically with either choice.
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
<CardGroup cols={2}>
<Card title="Lower ingestion cost" icon="dollar-sign">
Most managed log backends bill per event. A Python traceback in text format is counted as one event per line — 30+ events for a single error. JSON output collapses each traceback into a single event with the stack trace as an escaped string field.
</Card>
<Card title="Structured search" icon="magnifying-glass">
Search by `@automation_id`, `@exception.type`, `@kickoff_id` instead of grepping free-text. Build dashboards on typed facets without parser configuration.
</Card>
<Card title="APM ↔ logs correlation" icon="link">
Every event carries `trace_id` and `span_id` when fired inside a recording span, so backends auto-link logs to traces.
</Card>
<Card title="Stable contract" icon="file-shield">
The `schema` field gates compatibility — within `v1`, fields are added but never renamed or removed.
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:23.482914Z",
"level": "INFO",
"logger": "crewai_enterprise.utilities.pii_redaction",
"crewai_version": "1.14.7",
"msg": "PII tracking state reset (engines preserved)",
"automation_id": "12",
"task_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow"
}
```
An error with a Python exception is collapsed into a single event with the traceback as a string:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:31.218450Z",
"level": "ERROR",
"logger": "api.tasks.flow_run_task",
"crewai_version": "1.14.7",
"msg": "Flow execution failed",
"automation_id": "12",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow",
"exception": {
"type": "ValueError",
"message": "Topic cannot be empty",
"stacktrace": "Traceback (most recent call last):\n File \"/app/flow.py\", line 42, in summarize\n ...\nValueError: Topic cannot be empty\n"
}
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
Within the `v1` schema, fields are only added, never renamed or removed. New fields will appear as soon as a deployment is upgraded.
| Field | Type | Always present | Source |
|-------|------|----------------|--------|
| `schema` | string | Yes | Constant `"v1"`. Increment indicates a breaking schema change. |
| `ts` | string (ISO-8601 UTC, microseconds) | Yes | Record creation time, e.g. `2026-06-17T16:14:23.482914Z`. |
| `level` | string | Yes | Python log level name: `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. |
| `logger` | string | Yes | Dotted logger name, e.g. `api.tasks.flow_run_task`. |
| `crewai_version` | string | Yes (when `crewai` package metadata is resolvable) | Installed `crewai` package version, e.g. `"1.14.7"`. |
| `msg` | string | Yes | Rendered log message (after `%`-formatting / `{}`-formatting). |
| `automation_id` | string | When `CREWAI_PLUS_ID` env var is set | Numeric deployment ID (AMP provisions this on every container). |
| `task_id` | string | On Celery worker logs | Celery task UUID, or `"no-task"` for non-task contexts. |
| `kickoff_id` | string | Inside an automation kickoff | UUID of the current kickoff. |
| `execution_id` | string | Inside an automation kickoff | UUID of the current sub-execution. Equal to `kickoff_id` at the top level; differs for nested flow methods that spawn sub-executions. |
| `automation_name` | string | Inside an automation kickoff | Human-readable automation/flow name, e.g. `"research_flow"`. |
| `trace_id` | string (32-hex) | Inside a recording OpenTelemetry span | Hex trace ID. Omitted when no span is active. |
| `span_id` | string (16-hex) | Inside a recording OpenTelemetry span | Hex span ID. Omitted when no span is active. |
| `exception` | object | When the log record has `exc_info` | `{type, message, stacktrace}` — full traceback as a single escaped string. |
<Tip>
Any additional `extra={...}` kwargs passed to a logger call appear as top-level JSON fields verbatim. Reserved field names above always win to keep the schema stable.
</Tip>
### Stability promise
The `schema` field declares the contract. Within `v1`, CrewAI commits to:
- **Never removing a field** that customers may have built queries or dashboards against.
- **Never renaming a field** in place — renames happen via a schema bump (e.g. `v2`), with the old name kept as a deprecated alias for at least one release cycle.
- **Adding new fields** at any time. Consumers should ignore unknown top-level keys.
When a `v2` is introduced, both the `schema` field and the migration guide will be published in advance, and `v1` will continue to be emitted for one release cycle so dashboards and queries have time to migrate.
## Prerequisite: promote facets
Datadog auto-discovers fields the first time it sees them but doesn't make them queryable in widgets until they're promoted to **facets**. This is a one-time setup in your Datadog account.
<Steps>
<Step title="Search for a CrewAI log">
Open [Logs Explorer](https://app.datadoghq.com/logs) and search `service:crewai*`. You should see at least one log event.
</Step>
<Step title="Promote each field">
Click any log entry to open the right-hand details panel. For each field below, hover the field name → click the gear icon → **Create facet**.
- `automation_id`, `automation_name`, `execution_id`, `kickoff_id`, `task_id`
- `crewai_version`, `model_id`
- `exception.type`, `exception.message`
Skip any field that already shows a star icon next to its name — that means it's already a facet. The `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.request.model` facets are typically promoted automatically by Datadog's LLM Observability auto-discovery, but verify they exist before importing the dashboard.
</Step>
</Steps>
## Import the dashboard
<Steps>
<Step title="Download the dashboard JSON">
Save [`datadog_dashboard.json`](https://raw.githubusercontent.com/crewAIInc/crewAI/main/docs/edge/en/enterprise/guides/datadog_dashboard.json) to your machine.
</Step>
<Step title="Open the import dialog in Datadog">
Navigate to **Dashboards → New Dashboard**. Click the **gear icon** in the top right of the empty dashboard and select **Import Dashboard JSON**.
</Step>
<Step title="Paste or upload the JSON">
Paste the contents of `datadog_dashboard.json` into the import dialog (or drag the file in). Click **Import**.
Datadog creates the dashboard immediately and lands you on it. The first load may show empty widgets for a few seconds while queries execute against the time range.
</Step>
</Steps>
<Tip>
Datadog's [Dashboard API](https://docs.datadoghq.com/api/latest/dashboards/#create-a-new-dashboard) accepts the same JSON via `POST /api/v1/dashboard`. Use it if you manage dashboards through Terraform, Pulumi, or CI.
</Tip>
## What you get
The dashboard is organized into four sections plus a placeholder for a custom drill-down widget:
| Section | Widgets | Useful for |
|---------|---------|------------|
| **Header** | Total Executions · Error Rate (%) · Active Automations · CrewAI Versions in Use | At-a-glance health for the last hour. Error Rate is conditionally formatted (green ≤ 5%, yellow ≤ 10%, red > 10%). |
| **Throughput** | Executions per Hour by Automation (top 10, stacked bars) | Spotting traffic shifts, surfacing busy automations, validating that a rollout didn't change baseline volume. |
| **Errors** | Errors by Exception Type (top 5, stacked bars) · Top Exception Types by Count (toplist) | Triaging failures — which exception types are spiking, which automations they're hitting. |
| **Cost** | Total Tokens per Hour by Model (input + output, stacked area) | Tracking LLM token spend by model. Useful for catching cost regressions when an automation switches model or starts looping. |
| **Drill-Down** | _(empty placeholder)_ | See [Customization](#customize) for adding a recent-errors log stream here. |
Three template variables at the top of the dashboard re-scope every widget at once:
- **`$automation`** — filter to a single automation by name.
- **`$version`** — filter to a single `crewai` SDK version (useful for comparing pre- and post-upgrade behavior).
- **`$service`** — filter to a specific Datadog `service` tag (useful when multiple CrewAI deployments share one Datadog account).
## Verify ingestion
Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matches your ingestion path:
<Tabs>
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
If nothing appears, verify the collector endpoint is correct (`/v1/logs` for logs, `/v1/traces` for traces) and **Test Connection** succeeded when the collector was saved.
</Tab>
</Tabs>
## Customize
The dashboard ships with deliberate gaps so you can extend it without uninstalling and re-importing.
### Add a Recent Errors log stream
The **Drill-Down** section is intentionally empty. Add a Log Stream widget to it for an inline view of recent failures:
1. Edit the dashboard and click **+ Add Widgets** inside the Drill-Down group.
2. Drag in a **Log Stream** widget.
3. Set the filter query to `status:error $automation $version $service`.
4. Choose columns: `@timestamp`, `@automation_name`, `@exception.type`, `@exception.message`, `@execution_id`.
5. Sort by most recent, limit to 25 entries.
Clicking any row jumps to Logs Explorer with the same filter pre-applied.
### Add p95 latency
Logs don't include execution duration by default. Two ways to add a latency widget:
- **From APM traces** — if you also export OTLP traces to Datadog, add a Timeseries widget with data source **Traces**, query `service:crewai*`, aggregation `p95 of @duration`. Datadog APM auto-tracks span duration.
- **From metric extraction** — extract a `flow.duration_ms` metric from logs via [Datadog's log-to-metric pipeline](https://docs.datadoghq.com/logs/log_configuration/logs_to_metrics/), then chart it like any other metric. Useful if you don't run APM.
### Re-scope to multiple deployments
The `$service` template variable defaults to `*` and will catch every CrewAI deployment in your Datadog account. Change the default to a specific service name in **Configure → Template Variables** if you want the dashboard to focus on one deployment by default.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps
<CardGroup cols={2}>
<Card title="OpenTelemetry Export" icon="magnifying-glass-chart" href="./capture_telemetry_logs">
Vendor-neutral observability for non-Datadog stacks (Grafana, Honeycomb, your own collector) — or as a Datadog complement when you want to fan out telemetry to multiple backends.
</Card>
<Card title="Datadog Log Search Syntax" icon="magnifying-glass" href="https://docs.datadoghq.com/logs/explorer/search_syntax/">
Reference for customizing widget queries against the structured facets above.
</Card>
</CardGroup>

View File

@@ -4,6 +4,86 @@ description: "Atualizações de produto, melhorias e correções do CrewAI"
icon: "clock"
mode: "wide"
---
<Update label="18 jun 2026">
## v1.14.8a2
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a2)
## O que Mudou
### Funcionalidades
- Adicionar ação de agente único às definições de Fluxo
- Validar expressões CEL de fluxo no momento do carregamento da definição
### Documentação
- Adicionar guia de integração do Datadog com painel de operações importável
- Atualizar snapshot e changelog para v1.14.8a1
## Contributors
@joaomdmoura, @lucasgomide, @vinibrsl
</Update>
<Update label="18 jun 2026">
## v1.14.8a1
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a1)
## O que Mudou
### Recursos
- Adicionar expressão if opcional aos passos each.do
### Correções de Bugs
- Corrigir problemas de JSON da equipe
### Documentação
- Atualizar snapshot e changelog para v1.14.8a
## Contribuidores
@joaomdmoura, @vinibrsl
</Update>
<Update label="17 jun 2026">
## v1.14.8a
[Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a)
## O que Mudou
### Recursos
- Adicionar ação de bloco de script/código ao FlowDefinition
- Adicionar ações de equipe ao FlowDefinition
- Adicionar ação composta `each` ao FlowDefinition
- Implementar suporte ao modo DMN na criação e execução de equipes
- Melhorar a funcionalidade de redefinição de memória e o manuseio de equipes em JSON
- Adicionar expressões às ações do FlowDefinition
- Implementar ferramentas de execução de definição de fluxo sem código Python
- Conduzir feedback humano a partir da definição de fluxo
- Conectar configuração e persistência do FlowDefinition ao tempo de execução
- Adicionar `crewai run --definition` experimental para fluxos
- Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON
- Introduzir equipes em JSON primeiro
### Correções de Bugs
- Corrigir ferramenta Exa duplicada
- Corrigir uso de token agregado em todas as chamadas LLM
- Resolver problemas com o carregamento de equipes e lógica de validação
### Documentação
- Documentar campos do FlowDefinition no esquema JSON
- Atualizar documentação de instalação e início rápido para projetos de equipe em JSON-primeiro
- Atualizar changelog e versão para v1.14.7
## Contribuidores
@gabemilani, @greysonlalonde, @iris-clawd, @joaomdmoura, @lorenzejay, @lucasgomide, @theCyberTech, @vinibrsl
</Update>
<Update label="11 jun 2026">
## v1.14.7

View File

@@ -0,0 +1,87 @@
---
title: Um Card por Etapa
description: "Cada etapa no canvas do Studio é um único card que combina a tarefa e o agente que a executa."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
</Note>
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:
- **A tarefa** — o que fazer (nome, descrição, saída esperada e formato da resposta).
- **O agente** — quem faz (o agente atribuído, seu modelo e suas ferramentas).
Um agente não é um participante independente do seu fluxo de trabalho — ele é um atributo da tarefa: *qual agente executa este trabalho.* Colocar a tarefa e seu agente em um único card torna essa relação explícita e transforma sua automação em uma única cadeia de unidades de trabalho, da esquerda para a direita, mais fácil de ler em uma olhada.
<Frame caption="Um card por etapa: a tarefa com o agente atribuído resumido no rodapé.">
![Cards de etapa unificados no canvas](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## No canvas
Cada card recolhido mostra:
- O **nome e a descrição da tarefa** no topo.
- Um **rodapé resumindo o agente atribuído** — avatar, nome, modelo e ferramentas.
Não há nó de agente separado nem aresta vertical de agente → tarefa. Suas etapas se conectam diretamente umas às outras na ordem em que são executadas.
## No editor
Abra um card para editá-lo. A visão expandida é o mesmo card em um estado detalhado — não uma tela diferente — organizada em duas seções claramente identificadas.
<Frame caption="O editor expandido: a seção da tarefa aberta, com o agente resumido abaixo.">
![Editor de etapa expandido](/images/enterprise/merged-step-card-editor.png)
</Frame>
### A tarefa — o que fazer
Aberta por padrão, já que é o que você costuma editar:
- **Nome**
- **Descrição**
- **Saída Esperada**
- **Formato da Resposta** — exibido aqui porque controla exatamente o que as etapas seguintes (como o roteamento) leem desta etapa.
### O agente — quem faz
O agente atribuído é mostrado como um resumo — **nome, modelo e ferramentas em linha**. Sua configuração mais detalhada é preservada por trás de duas seções recolhíveis:
- **Papel, objetivo e história**
- **Configurações do agente** — raciocínio, máximo de tentativas de raciocínio, permitir delegação, máximo de iterações e configurações de LLM.
<Tip>
A configuração completa de um agente — Papel, Objetivo, História, Modelo, Ferramentas, Configurações de LLM e todo o bloco de Configurações do agente — fica por trás das seções recolhíveis **Papel, objetivo e história** e **Configurações do agente**, organizada pela frequência com que você a edita.
</Tip>
## Trocar vs. editar o agente
Há duas maneiras distintas de trabalhar com o agente em um card, e elas fazem coisas diferentes:
- **Trocar** reatribui *qual* agente executa esta tarefa. Use o controle **Trocar** para escolher um agente diferente deste projeto, selecionar um do seu Repositório de Agentes ou criar um novo agente. Isso tem escopo limitado à tarefa.
- **Editar** o agente — abrindo **Papel, objetivo e história** ou **Configurações do agente** — altera o agente *em si*.
<Frame caption="Trocar muda qual agente executa a tarefa.">
![Painel de troca de agente](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**Os agentes são reutilizáveis e compartilhados.** O mesmo agente pode executar mais de uma tarefa em todo o seu projeto. Editar o papel, a história ou as configurações de um agente atualiza esse agente **em todos os lugares onde ele é usado** — não apenas no card que você abriu. Se quiser que uma alteração se aplique a apenas uma etapa, **Troque** por um agente diferente em vez de editar o agente compartilhado.
</Warning>
## Relacionados
<CardGroup cols={2}>
<Card title="Crew Studio" href="/pt-BR/enterprise/features/crew-studio" icon="pencil">
Crie automações com assistência de IA e um editor visual.
</Card>
<Card title="Repositórios de Agentes" href="/pt-BR/enterprise/features/agent-repositories" icon="users">
Gerencie e reutilize agentes em suas automações.
</Card>
</CardGroup>

View File

@@ -9,6 +9,10 @@ O CrewAI AMP pode exportar **traces** e **logs** do OpenTelemetry das suas impla
Os dados de telemetria seguem as [convenções semânticas GenAI do OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/gen-ai/) além de atributos adicionais específicos do CrewAI.
<Tip>
OpenTelemetry é o **caminho de observabilidade recomendado** — neutro em relação a fornecedores, funciona com qualquer backend compatível com OTLP (Grafana, Honeycomb, NewRelic, seu próprio coletor). Se você usa especificamente o Datadog, veja o guia dedicado [Integração com Datadog](./datadog), que cobre tanto o caminho do Datadog Agent quanto o ingest OTLP do Datadog.
</Tip>
## Pré-requisitos
<CardGroup cols={2}>
@@ -41,17 +45,7 @@ Os dados de telemetria seguem as [convenções semânticas GenAI do OpenTelemetr
<Frame>![Configuração do coletor OpenTelemetry](/images/crewai-otel-collector-opentelemetry.png)</Frame>
</Tab>
<Tab title="Datadog">
- **Datadog Site Domain** — Apenas o host OTLP do seu site Datadog, sem protocolo ou caminho. O CrewAI monta o endpoint HTTPS OTLP completo para você. Use o host correspondente ao seu [site Datadog](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — Sua chave de API do Datadog. Veja [como criar uma](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
A integração com o Datadog exporta **traces**.
<Frame>![Configuração do coletor Datadog](/images/crewai-otel-collector-datadog.png)</Frame>
Para configurar o Datadog, veja o guia dedicado [Integração com Datadog](./datadog) — ele cobre tanto o caminho do Datadog Agent (recomendado, mais barato para volumes altos de log) quanto o ingest OTLP do Datadog, com os passos completos de configuração do coletor.
</Tab>
</Tabs>

View File

@@ -0,0 +1,295 @@
---
title: "Integração com Datadog"
description: "Monitore implantações CrewAI AMP auto-hospedadas no Datadog via Datadog Agent ou ingest OTLP do Datadog — ambos os caminhos entregam as mesmas facetas estruturadas para importar o dashboard de operações pronto."
icon: "dog"
mode: "wide"
---
<Note>
**Tradução em andamento** — conteúdo exibido em inglês.
</Note>
CrewAI ships first-class support for Datadog: two log-ingestion paths, a JSON log schema designed for cheap indexing, and a ready-made operations dashboard you can import in under five minutes.
<Note>
For vendor-neutral observability via any OTLP backend (Grafana, Honeycomb, your own collector), see [OpenTelemetry Export](./capture_telemetry_logs).
</Note>
## Choose a path
CrewAI supports two log-ingestion paths to Datadog — both are first-class and produce the same structured facets that power the dashboard. Pick the one that fits your infrastructure.
<Tabs>
<Tab title="Datadog Agent">
The Datadog Agent runs alongside your CrewAI containers (typically as a DaemonSet on Kubernetes) and tails their stdout. With `CREWAI_LOG_FORMAT=json` set, each log event ships as a single billable line with structured attributes.
**Setup:**
1. Run the Datadog Agent next to your CrewAI containers — see [Datadog's deployment docs](https://docs.datadoghq.com/agent/) for Kubernetes, ECS, or VM setup. Enable log collection (`logs_enabled: true`) and container log collection (`logs_config.container_collect_all: true`).
2. Set `CREWAI_LOG_FORMAT=json` as an **automation environment variable** in CrewAI AMP (open your automation → **Settings → Environment Variables**) so each log event is a single line instead of a multi-line traceback. AMP propagates the value to every container in the deployment (API + workers) — don't set it on the container or host directly. See [Enabling JSON output](#enabling-json-output) below for the AMP UI walkthrough and the [log schema reference](#log-schema-reference) for the full field contract.
3. Confirm logs arrive in Datadog Logs with the JSON fields parsed — see [Verify ingestion](#verify-ingestion).
**Pick this path if** you already operate Datadog Agents (e.g. for infrastructure metrics), or your log volume makes per-event ingestion cost a real concern — collapsing tracebacks into single events keeps Agent ingestion cheap at scale.
</Tab>
<Tab title="Datadog OTLP intake">
CrewAI AMP exports OpenTelemetry traffic directly to Datadog's OTLP endpoint with no Agent required. Logs and traces ride a single export pipeline configured in AMP's UI, using the same protocol you'd use for any other OTLP backend.
**Setup:**
1. In CrewAI AMP, go to **Settings → OpenTelemetry Collectors → Add Collector** and pick **Datadog**.
2. Configure the connection:
- **Datadog Site Domain** — your Datadog site's OTLP host only, no protocol or path. CrewAI builds the full HTTPS OTLP endpoint for you. Use the host that matches your [Datadog site](https://docs.datadoghq.com/getting_started/site/):
- `otlp.datadoghq.com` (US1)
- `otlp.us3.datadoghq.com` (US3)
- `otlp.us5.datadoghq.com` (US5)
- `otlp.datadoghq.eu` (EU1)
- `otlp.ap1.datadoghq.com` (AP1)
- **API Key** — your Datadog API key. See [how to create one](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys).
3. The Datadog template provisions **both signals at once** — when you save, AMP creates a traces collector at `/v1/traces` and a logs collector at `/v1/logs`, both sharing the same Datadog OTLP host and API key. You'll see them as two separate rows in your OTel collectors list.
4. *(optional)* Click **Test Connection** to verify CrewAI can reach the endpoint with the credentials you provided. Then click **Save** — both collectors are created in one step.
<Frame>![Datadog collector configuration](/images/crewai-otel-collector-datadog.png)</Frame>
**Pick this path if** you'd rather not operate a Datadog Agent, you already use OTLP for traces and want one export pipeline, or you may later want to fan out the same telemetry to other backends (Grafana, Honeycomb, etc.) without changing your application setup.
</Tab>
</Tabs>
Either path lands the same structured facets in Datadog (`@automation_id`, `@kickoff_id`, `@execution_id`, `@automation_name`, `@crewai_version`, `@exception.type`, `@gen_ai.*`), so the dashboard works identically with either choice.
## Log schema reference
<Info>
This schema applies to the **Datadog Agent path** — stdout JSON logs produced when `CREWAI_LOG_FORMAT=json` is set. Logs delivered via the **Datadog OTLP intake** use OpenTelemetry attribute names and may differ; see [OpenTelemetry Export](./capture_telemetry_logs).
</Info>
When `CREWAI_LOG_FORMAT=json` is set, every log event is emitted as a **single JSON object per line** to stdout, with internal newlines escaped. The format is plain JSON — Datadog parses it natively, and the same payload is also consumable by Splunk, Loki, Elasticsearch, and CloudWatch without custom log pipelines.
### Why JSON output
<CardGroup cols={2}>
<Card title="Lower ingestion cost" icon="dollar-sign">
Most managed log backends bill per event. A Python traceback in text format is counted as one event per line — 30+ events for a single error. JSON output collapses each traceback into a single event with the stack trace as an escaped string field.
</Card>
<Card title="Structured search" icon="magnifying-glass">
Search by `@automation_id`, `@exception.type`, `@kickoff_id` instead of grepping free-text. Build dashboards on typed facets without parser configuration.
</Card>
<Card title="APM ↔ logs correlation" icon="link">
Every event carries `trace_id` and `span_id` when fired inside a recording span, so backends auto-link logs to traces.
</Card>
<Card title="Stable contract" icon="file-shield">
The `schema` field gates compatibility — within `v1`, fields are added but never renamed or removed.
</Card>
</CardGroup>
### Enabling JSON output
`CREWAI_LOG_FORMAT=json` must be set as an **automation environment variable** in CrewAI AMP — it is **not** a container, host, or Docker setting. Open your automation in AMP, click the **Settings** icon, and add the variable under the **Environment Variables** section. AMP applies the value to every container in the deployment (API + workers) on the next restart. See [Update Your Crew](./update-crew) for the full UI walkthrough with screenshots.
```shell
CREWAI_LOG_FORMAT=json
```
Restart the deployment to pick up the change. Every log line on stdout from that point on is a single JSON object.
<Note>
The default value is `text`, which preserves the legacy human-readable line format byte-for-byte. Setting any value other than `json` falls back to text mode. There is no migration step — the variable is read at process start and the format switches immediately.
</Note>
### Example events
A single info-level log inside an active automation kickoff:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:23.482914Z",
"level": "INFO",
"logger": "crewai_enterprise.utilities.pii_redaction",
"crewai_version": "1.14.7",
"msg": "PII tracking state reset (engines preserved)",
"automation_id": "12",
"task_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow"
}
```
An error with a Python exception is collapsed into a single event with the traceback as a string:
```json
{
"schema": "v1",
"ts": "2026-06-17T16:14:31.218450Z",
"level": "ERROR",
"logger": "api.tasks.flow_run_task",
"crewai_version": "1.14.7",
"msg": "Flow execution failed",
"automation_id": "12",
"kickoff_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"execution_id": "0843a930-b306-464b-89c8-bfafa78cc711",
"automation_name": "research_flow",
"exception": {
"type": "ValueError",
"message": "Topic cannot be empty",
"stacktrace": "Traceback (most recent call last):\n File \"/app/flow.py\", line 42, in summarize\n ...\nValueError: Topic cannot be empty\n"
}
}
```
The same error in legacy text mode would have produced ~25 separate log events (one per traceback line) — all of which the backend would bill and index individually.
### Schema v1 fields
Within the `v1` schema, fields are only added, never renamed or removed. New fields will appear as soon as a deployment is upgraded.
| Field | Type | Always present | Source |
|-------|------|----------------|--------|
| `schema` | string | Yes | Constant `"v1"`. Increment indicates a breaking schema change. |
| `ts` | string (ISO-8601 UTC, microseconds) | Yes | Record creation time, e.g. `2026-06-17T16:14:23.482914Z`. |
| `level` | string | Yes | Python log level name: `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. |
| `logger` | string | Yes | Dotted logger name, e.g. `api.tasks.flow_run_task`. |
| `crewai_version` | string | Yes (when `crewai` package metadata is resolvable) | Installed `crewai` package version, e.g. `"1.14.7"`. |
| `msg` | string | Yes | Rendered log message (after `%`-formatting / `{}`-formatting). |
| `automation_id` | string | When `CREWAI_PLUS_ID` env var is set | Numeric deployment ID (AMP provisions this on every container). |
| `task_id` | string | On Celery worker logs | Celery task UUID, or `"no-task"` for non-task contexts. |
| `kickoff_id` | string | Inside an automation kickoff | UUID of the current kickoff. |
| `execution_id` | string | Inside an automation kickoff | UUID of the current sub-execution. Equal to `kickoff_id` at the top level; differs for nested flow methods that spawn sub-executions. |
| `automation_name` | string | Inside an automation kickoff | Human-readable automation/flow name, e.g. `"research_flow"`. |
| `trace_id` | string (32-hex) | Inside a recording OpenTelemetry span | Hex trace ID. Omitted when no span is active. |
| `span_id` | string (16-hex) | Inside a recording OpenTelemetry span | Hex span ID. Omitted when no span is active. |
| `exception` | object | When the log record has `exc_info` | `{type, message, stacktrace}` — full traceback as a single escaped string. |
<Tip>
Any additional `extra={...}` kwargs passed to a logger call appear as top-level JSON fields verbatim. Reserved field names above always win to keep the schema stable.
</Tip>
### Stability promise
The `schema` field declares the contract. Within `v1`, CrewAI commits to:
- **Never removing a field** that customers may have built queries or dashboards against.
- **Never renaming a field** in place — renames happen via a schema bump (e.g. `v2`), with the old name kept as a deprecated alias for at least one release cycle.
- **Adding new fields** at any time. Consumers should ignore unknown top-level keys.
When a `v2` is introduced, both the `schema` field and the migration guide will be published in advance, and `v1` will continue to be emitted for one release cycle so dashboards and queries have time to migrate.
## Prerequisite: promote facets
Datadog auto-discovers fields the first time it sees them but doesn't make them queryable in widgets until they're promoted to **facets**. This is a one-time setup in your Datadog account.
<Steps>
<Step title="Search for a CrewAI log">
Open [Logs Explorer](https://app.datadoghq.com/logs) and search `service:crewai*`. You should see at least one log event.
</Step>
<Step title="Promote each field">
Click any log entry to open the right-hand details panel. For each field below, hover the field name → click the gear icon → **Create facet**.
- `automation_id`, `automation_name`, `execution_id`, `kickoff_id`, `task_id`
- `crewai_version`, `model_id`
- `exception.type`, `exception.message`
Skip any field that already shows a star icon next to its name — that means it's already a facet. The `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, and `gen_ai.request.model` facets are typically promoted automatically by Datadog's LLM Observability auto-discovery, but verify they exist before importing the dashboard.
</Step>
</Steps>
## Import the dashboard
<Steps>
<Step title="Download the dashboard JSON">
Save [`datadog_dashboard.json`](https://raw.githubusercontent.com/crewAIInc/crewAI/main/docs/edge/en/enterprise/guides/datadog_dashboard.json) to your machine.
</Step>
<Step title="Open the import dialog in Datadog">
Navigate to **Dashboards → New Dashboard**. Click the **gear icon** in the top right of the empty dashboard and select **Import Dashboard JSON**.
</Step>
<Step title="Paste or upload the JSON">
Paste the contents of `datadog_dashboard.json` into the import dialog (or drag the file in). Click **Import**.
Datadog creates the dashboard immediately and lands you on it. The first load may show empty widgets for a few seconds while queries execute against the time range.
</Step>
</Steps>
<Tip>
Datadog's [Dashboard API](https://docs.datadoghq.com/api/latest/dashboards/#create-a-new-dashboard) accepts the same JSON via `POST /api/v1/dashboard`. Use it if you manage dashboards through Terraform, Pulumi, or CI.
</Tip>
## What you get
The dashboard is organized into four sections plus a placeholder for a custom drill-down widget:
| Section | Widgets | Useful for |
|---------|---------|------------|
| **Header** | Total Executions · Error Rate (%) · Active Automations · CrewAI Versions in Use | At-a-glance health for the last hour. Error Rate is conditionally formatted (green ≤ 5%, yellow ≤ 10%, red > 10%). |
| **Throughput** | Executions per Hour by Automation (top 10, stacked bars) | Spotting traffic shifts, surfacing busy automations, validating that a rollout didn't change baseline volume. |
| **Errors** | Errors by Exception Type (top 5, stacked bars) · Top Exception Types by Count (toplist) | Triaging failures — which exception types are spiking, which automations they're hitting. |
| **Cost** | Total Tokens per Hour by Model (input + output, stacked area) | Tracking LLM token spend by model. Useful for catching cost regressions when an automation switches model or starts looping. |
| **Drill-Down** | _(empty placeholder)_ | See [Customization](#customize) for adding a recent-errors log stream here. |
Three template variables at the top of the dashboard re-scope every widget at once:
- **`$automation`** — filter to a single automation by name.
- **`$version`** — filter to a single `crewai` SDK version (useful for comparing pre- and post-upgrade behavior).
- **`$service`** — filter to a specific Datadog `service` tag (useful when multiple CrewAI deployments share one Datadog account).
## Verify ingestion
Open [Logs Explorer](https://app.datadoghq.com/logs) and run a query that matches your ingestion path:
<Tabs>
<Tab title="Datadog Agent">
Search `service:crewai* @schema:v1`. You should see structured logs with the JSON fields parsed into Datadog facets. Pick a recent event and verify it has `@automation_id`, `@kickoff_id`, `@execution_id`, `@crewai_version`, and (when running inside a span) `@trace_id` / `@span_id` populated.
If nothing appears, confirm `CREWAI_LOG_FORMAT=json` is set under your automation's **Environment Variables** in AMP, the deployment was restarted after the change, and the Datadog Agent is tailing container stdout.
</Tab>
<Tab title="Datadog OTLP intake">
Search `source:otlp service:crewai*`. OTLP attributes land with their OpenTelemetry names (`automation_id`, `crewai.kickoff.id`, etc.) rather than the stdout JSON keys, but they map to the same dashboard facets after [facet promotion](#prerequisite-promote-facets).
If nothing appears, verify the collector endpoint is correct (`/v1/logs` for logs, `/v1/traces` for traces) and **Test Connection** succeeded when the collector was saved.
</Tab>
</Tabs>
## Customize
The dashboard ships with deliberate gaps so you can extend it without uninstalling and re-importing.
### Add a Recent Errors log stream
The **Drill-Down** section is intentionally empty. Add a Log Stream widget to it for an inline view of recent failures:
1. Edit the dashboard and click **+ Add Widgets** inside the Drill-Down group.
2. Drag in a **Log Stream** widget.
3. Set the filter query to `status:error $automation $version $service`.
4. Choose columns: `@timestamp`, `@automation_name`, `@exception.type`, `@exception.message`, `@execution_id`.
5. Sort by most recent, limit to 25 entries.
Clicking any row jumps to Logs Explorer with the same filter pre-applied.
### Add p95 latency
Logs don't include execution duration by default. Two ways to add a latency widget:
- **From APM traces** — if you also export OTLP traces to Datadog, add a Timeseries widget with data source **Traces**, query `service:crewai*`, aggregation `p95 of @duration`. Datadog APM auto-tracks span duration.
- **From metric extraction** — extract a `flow.duration_ms` metric from logs via [Datadog's log-to-metric pipeline](https://docs.datadoghq.com/logs/log_configuration/logs_to_metrics/), then chart it like any other metric. Useful if you don't run APM.
### Re-scope to multiple deployments
The `$service` template variable defaults to `*` and will catch every CrewAI deployment in your Datadog account. Change the default to a specific service name in **Configure → Template Variables** if you want the dashboard to focus on one deployment by default.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| All widgets show "No data" | Facets aren't promoted | Re-do the [Promote facets](#prerequisite-promote-facets) step. Datadog won't query against an un-promoted field. |
| Error Rate widget shows `NaN` | No executions in the time window | Either no traffic, or `@execution_id` isn't faceted. Expand the time range and re-check facets. |
| Throughput chart is flat at the same value | Logs aren't reaching Datadog | Search `service:crewai*` in Logs Explorer. If nothing shows, verify the Datadog Agent is running (Agent path) or the OTel collector endpoint is correct (OTLP path). |
| `crewai_version` shows fewer values than expected | Some containers predate the structured-logs work | The `crewai_version` field was added alongside JSON output. Older deployments running text mode (or older AMP builds) won't emit it. Upgrade those deployments to pick up the field. See the [log schema reference](#log-schema-reference) for the full field contract. |
| Template variables don't filter widgets | The widget's filter line doesn't reference the template variable | Edit the widget and confirm the search includes `$automation $version $service`. |
## Next steps
<CardGroup cols={2}>
<Card title="OpenTelemetry Export" icon="magnifying-glass-chart" href="./capture_telemetry_logs">
Vendor-neutral observability for non-Datadog stacks (Grafana, Honeycomb, your own collector) — or as a Datadog complement when you want to fan out telemetry to multiple backends.
</Card>
<Card title="Datadog Log Search Syntax" icon="magnifying-glass" href="https://docs.datadoghq.com/logs/explorer/search_syntax/">
Reference for customizing widget queries against the structured facets above.
</Card>
</CardGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -0,0 +1,87 @@
---
title: بطاقة واحدة لكل خطوة
description: "كل خطوة على لوحة Studio هي بطاقة واحدة تجمع بين المهمة والوكيل الذي ينفّذها."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة.
</Note>
## نظرة عامة
على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة:
- **المهمة** — ماذا تفعل (الاسم، الوصف، المخرجات المتوقعة، وتنسيق الاستجابة).
- **الوكيل** — من ينفّذها (الوكيل المُعيَّن ونموذجه وأدواته).
الوكيل ليس مشاركًا مستقلاً في سير العمل لديك — بل هو سمة من سمات المهمة: *أي وكيل ينفّذ هذا العمل.* وضع المهمة والوكيل في بطاقة واحدة يجعل هذه العلاقة واضحة، ويحوّل أتمتتك إلى سلسلة واحدة من وحدات العمل من اليسار إلى اليمين يسهل قراءتها بنظرة واحدة.
<Frame caption="بطاقة واحدة لكل خطوة: المهمة مع ملخص للوكيل المُعيَّن في التذييل.">
![بطاقات الخطوات الموحّدة على اللوحة](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## على اللوحة
تعرض كل بطاقة مطوية ما يلي:
- **اسم المهمة ووصفها** في الأعلى.
- **تذييل يلخّص الوكيل المُعيَّن** — الصورة الرمزية والاسم والنموذج والأدوات.
لا توجد عقدة وكيل منفصلة ولا حافة عمودية من الوكيل ← المهمة. تتصل خطواتك مباشرةً ببعضها البعض بالترتيب الذي تُنفَّذ به.
## في المحرّر
افتح بطاقة لتحريرها. العرض الموسّع هو البطاقة نفسها في حالة مفصّلة — وليس شاشة مختلفة — منظّمة في قسمين موسومين بوضوح.
<Frame caption="المحرّر الموسّع: قسم المهمة مفتوح، والوكيل ملخّص أسفله.">
![محرّر الخطوة الموسّع](/images/enterprise/merged-step-card-editor.png)
</Frame>
### المهمة — ماذا تفعل
مفتوحة افتراضيًا، لأنها ما تحرّره عادةً:
- **الاسم**
- **الوصف**
- **المخرجات المتوقعة**
- **تنسيق الاستجابة** — يظهر هنا لأنه يتحكم تحديدًا في ما تقرأه الخطوات اللاحقة (مثل التوجيه) من هذه الخطوة.
### الوكيل — من ينفّذها
يُعرض الوكيل المُعيَّن كملخّص — **الاسم والنموذج والأدوات في سطر واحد**. ويُحفَظ إعداده الأعمق خلف قسمين قابلين للطي:
- **الدور والهدف والخلفية**
- **إعدادات الوكيل** — الاستدلال، الحد الأقصى لمحاولات الاستدلال، السماح بالتفويض، الحد الأقصى للتكرارات، وإعدادات LLM.
<Tip>
الإعداد الكامل للوكيل — الدور، الهدف، الخلفية، النموذج، الأدوات، إعدادات LLM، وكامل كتلة إعدادات الوكيل — موجود خلف القسمين القابلين للطي **الدور والهدف والخلفية** و**إعدادات الوكيل**، منظّمًا حسب عدد مرّات تحريرك له.
</Tip>
## التبديل مقابل تحرير الوكيل
هناك طريقتان متمايزتان للتعامل مع الوكيل في البطاقة، وكل منهما تؤدي وظيفة مختلفة:
- **التبديل (Swap)** يعيد تعيين *أي* وكيل ينفّذ هذه المهمة. استخدم عنصر التحكم **تبديل** لاختيار وكيل مختلف من هذا المشروع، أو اختيار واحد من مستودع الوكلاء، أو إنشاء وكيل جديد. هذا مقصور على نطاق المهمة.
- **تحرير** الوكيل — بفتح **الدور والهدف والخلفية** أو **إعدادات الوكيل** — يغيّر الوكيل *نفسه*.
<Frame caption="التبديل يغيّر الوكيل الذي ينفّذ المهمة.">
![لوحة تبديل الوكيل](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**الوكلاء قابلون لإعادة الاستخدام ومشتركون.** يمكن للوكيل نفسه تنفيذ أكثر من مهمة عبر مشروعك. تحرير دور الوكيل أو خلفيته أو إعداداته يحدّث ذلك الوكيل **في كل مكان يُستخدم فيه** — وليس فقط في البطاقة التي فتحتها. إذا أردت تطبيق تغيير على خطوة واحدة فقط، فقم **بالتبديل** إلى وكيل مختلف بدلاً من تحرير الوكيل المشترك.
</Warning>
## ذات صلة
<CardGroup cols={2}>
<Card title="Crew Studio" href="/ar/enterprise/features/crew-studio" icon="pencil">
أنشئ الأتمتة بمساعدة الذكاء الاصطناعي ومحرّر مرئي.
</Card>
<Card title="مستودعات الوكلاء" href="/ar/enterprise/features/agent-repositories" icon="users">
إدارة الوكلاء وإعادة استخدامهم عبر أتمتتك.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: One Card per Step
description: "Each step on the Studio canvas is a single card that combines the task and the agent that performs it."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card.
</Note>
## Overview
On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes:
- **The task** — what to do (name, description, expected output, and response format).
- **The agent** — who does it (the assigned agent, its model, and its tools).
An agent isn't an independent participant in your workflow — it's an attribute of the task: *which agent performs this work.* Putting the task and its agent on one card makes that relationship explicit and turns your automation into a single, left-to-right chain of work units that's easier to read at a glance.
<Frame caption="One card per step: the task with its assigned agent summarized in the footer.">
![Merged step cards on the canvas](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## On the canvas
Each collapsed card shows:
- The **task name and description** at the top.
- A **footer summarizing the assigned agent** — avatar, name, model, and tools.
There's no separate agent node and no vertical agent → task edge. Your steps connect directly to one another in the order they run.
## In the editor
Open a card to edit it. The expanded view is the same card in a detailed state — not a different screen — organized into two clearly labeled sections.
<Frame caption="The expanded editor: the task section open, the agent summarized below it.">
![Expanded step editor](/images/enterprise/merged-step-card-editor.png)
</Frame>
### The task — what to do
Open by default, since this is what you usually edit:
- **Name**
- **Description**
- **Expected Output**
- **Response Format** — surfaced here because it controls exactly what downstream steps (such as routing) read from this step.
### The agent — who does it
The assigned agent is shown as a summary — **name, model, and tools inline**. Its deeper configuration is preserved behind two disclosures:
- **Role, goal & backstory**
- **Agent settings** — reasoning, max reasoning attempts, allow delegation, max iterations, and LLM settings.
<Tip>
An agent's full configuration — Role, Goal, Backstory, Model, Tools, LLM Settings, and the complete Agent Settings block — lives behind the **Role, goal & backstory** and **Agent settings** disclosures, organized by how often you edit it.
</Tip>
## Swapping vs. editing the agent
There are two distinct ways to work with the agent on a card, and they do different things:
- **Swap** reassigns *which* agent performs this task. Use the **Swap** control to pick a different agent from this project, choose one from your Agent Repository, or create a new agent. This is scoped to the task.
- **Editing** the agent — opening **Role, goal & backstory** or **Agent settings** — changes the agent *itself*.
<Frame caption="Swap changes which agent performs the task.">
![Swap agent panel](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**Agents are reusable and shared.** The same agent can perform more than one task across your project. Editing an agent's role, backstory, or settings updates that agent **everywhere it's used** — not just on the card you opened. If you want a change to apply to only one step, **Swap** in a different agent instead of editing the shared one.
</Warning>
## Related
<CardGroup cols={2}>
<Card title="Crew Studio" href="/en/enterprise/features/crew-studio" icon="pencil">
Build automations with AI assistance and a visual editor.
</Card>
<Card title="Agent Repositories" href="/en/enterprise/features/agent-repositories" icon="users">
Manage and reuse agents across your automations.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: 단계당 하나의 카드
description: "Studio 캔버스의 각 단계는 작업과 이를 수행하는 에이전트를 하나로 결합한 단일 카드입니다."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다.
</Note>
## 개요
Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다:
- **작업(Task)** — 무엇을 할지(이름, 설명, 예상 출력, 응답 형식).
- **에이전트(Agent)** — 누가 수행하는지(할당된 에이전트, 모델, 도구).
에이전트는 워크플로의 독립적인 참여자가 아니라 작업의 속성, 즉 *이 작업을 어떤 에이전트가 수행하는지*를 나타냅니다. 작업과 에이전트를 하나의 카드에 두면 이 관계가 명확해지고, 자동화가 왼쪽에서 오른쪽으로 이어지는 단일 작업 단위 체인이 되어 한눈에 읽기 쉬워집니다.
<Frame caption="단계당 하나의 카드: 작업과 푸터에 요약된 할당 에이전트.">
![캔버스의 통합 단계 카드](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## 캔버스에서
접힌 각 카드는 다음을 표시합니다:
- 상단의 **작업 이름과 설명**.
- **할당된 에이전트를 요약한 푸터** — 아바타, 이름, 모델, 도구.
별도의 에이전트 노드나 에이전트 → 작업 세로 연결선이 없습니다. 각 단계는 실행 순서대로 서로 직접 연결됩니다.
## 에디터에서
카드를 열어 편집합니다. 확장된 보기는 다른 화면이 아니라 동일한 카드의 상세 상태이며, 명확하게 구분된 두 개의 섹션으로 구성됩니다.
<Frame caption="확장된 에디터: 작업 섹션이 열려 있고 그 아래에 에이전트가 요약되어 있습니다.">
![확장된 단계 에디터](/images/enterprise/merged-step-card-editor.png)
</Frame>
### 작업 — 무엇을 할지
가장 자주 편집하는 항목이므로 기본적으로 열려 있습니다:
- **이름**
- **설명**
- **예상 출력**
- **응답 형식** — 다운스트림 단계(예: 라우팅)가 이 단계에서 무엇을 읽을지 정확히 제어하므로 여기에 표시됩니다.
### 에이전트 — 누가 수행하는지
할당된 에이전트는 요약으로 표시됩니다 — **이름, 모델, 도구가 인라인으로** 표시됩니다. 더 깊은 구성은 두 개의 접이식 섹션 뒤에 보존됩니다:
- **역할, 목표 및 배경 스토리**
- **에이전트 설정** — 추론, 최대 추론 시도 횟수, 위임 허용, 최대 반복 횟수, LLM 설정.
<Tip>
에이전트의 전체 구성 — 역할, 목표, 배경 스토리, 모델, 도구, LLM 설정 및 전체 에이전트 설정 블록 — 은 **역할, 목표 및 배경 스토리**와 **에이전트 설정** 접이식 섹션 뒤에 편집 빈도에 따라 정리되어 있습니다.
</Tip>
## 에이전트 교체 vs. 편집
카드에서 에이전트를 다루는 방식은 두 가지로 구분되며, 각각 다른 작업을 수행합니다:
- **교체(Swap)** 는 *어떤* 에이전트가 이 작업을 수행할지 재할당합니다. **교체** 컨트롤을 사용하여 이 프로젝트의 다른 에이전트를 선택하거나, 에이전트 저장소에서 선택하거나, 새 에이전트를 만들 수 있습니다. 이는 작업 범위로 한정됩니다.
- 에이전트 **편집** — **역할, 목표 및 배경 스토리** 또는 **에이전트 설정** 을 여는 것 — 은 에이전트 *자체*를 변경합니다.
<Frame caption="교체는 작업을 수행할 에이전트를 변경합니다.">
![에이전트 교체 패널](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**에이전트는 재사용 가능하며 공유됩니다.** 동일한 에이전트가 프로젝트 전반에서 둘 이상의 작업을 수행할 수 있습니다. 에이전트의 역할, 배경 스토리 또는 설정을 편집하면 열어 본 카드뿐만 아니라 **해당 에이전트가 사용되는 모든 곳**에서 업데이트됩니다. 변경 사항을 하나의 단계에만 적용하려면 공유 에이전트를 편집하지 말고 다른 에이전트로 **교체**하세요.
</Warning>
## 관련 항목
<CardGroup cols={2}>
<Card title="Crew Studio" href="/ko/enterprise/features/crew-studio" icon="pencil">
AI 지원과 비주얼 에디터로 자동화를 구축합니다.
</Card>
<Card title="에이전트 저장소" href="/ko/enterprise/features/agent-repositories" icon="users">
자동화 전반에서 에이전트를 관리하고 재사용합니다.
</Card>
</CardGroup>

View File

@@ -0,0 +1,87 @@
---
title: Um Card por Etapa
description: "Cada etapa no canvas do Studio é um único card que combina a tarefa e o agente que a executa."
icon: "layer-group"
mode: "wide"
---
{/* CLEANUP: This <Note> banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */}
<Note>
**Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card.
</Note>
## Visão geral
No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados:
- **A tarefa** — o que fazer (nome, descrição, saída esperada e formato da resposta).
- **O agente** — quem faz (o agente atribuído, seu modelo e suas ferramentas).
Um agente não é um participante independente do seu fluxo de trabalho — ele é um atributo da tarefa: *qual agente executa este trabalho.* Colocar a tarefa e seu agente em um único card torna essa relação explícita e transforma sua automação em uma única cadeia de unidades de trabalho, da esquerda para a direita, mais fácil de ler em uma olhada.
<Frame caption="Um card por etapa: a tarefa com o agente atribuído resumido no rodapé.">
![Cards de etapa unificados no canvas](/images/enterprise/merged-step-card-canvas.png)
</Frame>
## No canvas
Cada card recolhido mostra:
- O **nome e a descrição da tarefa** no topo.
- Um **rodapé resumindo o agente atribuído** — avatar, nome, modelo e ferramentas.
Não há nó de agente separado nem aresta vertical de agente → tarefa. Suas etapas se conectam diretamente umas às outras na ordem em que são executadas.
## No editor
Abra um card para editá-lo. A visão expandida é o mesmo card em um estado detalhado — não uma tela diferente — organizada em duas seções claramente identificadas.
<Frame caption="O editor expandido: a seção da tarefa aberta, com o agente resumido abaixo.">
![Editor de etapa expandido](/images/enterprise/merged-step-card-editor.png)
</Frame>
### A tarefa — o que fazer
Aberta por padrão, já que é o que você costuma editar:
- **Nome**
- **Descrição**
- **Saída Esperada**
- **Formato da Resposta** — exibido aqui porque controla exatamente o que as etapas seguintes (como o roteamento) leem desta etapa.
### O agente — quem faz
O agente atribuído é mostrado como um resumo — **nome, modelo e ferramentas em linha**. Sua configuração mais detalhada é preservada por trás de duas seções recolhíveis:
- **Papel, objetivo e história**
- **Configurações do agente** — raciocínio, máximo de tentativas de raciocínio, permitir delegação, máximo de iterações e configurações de LLM.
<Tip>
A configuração completa de um agente — Papel, Objetivo, História, Modelo, Ferramentas, Configurações de LLM e todo o bloco de Configurações do agente — fica por trás das seções recolhíveis **Papel, objetivo e história** e **Configurações do agente**, organizada pela frequência com que você a edita.
</Tip>
## Trocar vs. editar o agente
Há duas maneiras distintas de trabalhar com o agente em um card, e elas fazem coisas diferentes:
- **Trocar** reatribui *qual* agente executa esta tarefa. Use o controle **Trocar** para escolher um agente diferente deste projeto, selecionar um do seu Repositório de Agentes ou criar um novo agente. Isso tem escopo limitado à tarefa.
- **Editar** o agente — abrindo **Papel, objetivo e história** ou **Configurações do agente** — altera o agente *em si*.
<Frame caption="Trocar muda qual agente executa a tarefa.">
![Painel de troca de agente](/images/enterprise/merged-step-card-swap-agent.png)
</Frame>
<Warning>
**Os agentes são reutilizáveis e compartilhados.** O mesmo agente pode executar mais de uma tarefa em todo o seu projeto. Editar o papel, a história ou as configurações de um agente atualiza esse agente **em todos os lugares onde ele é usado** — não apenas no card que você abriu. Se quiser que uma alteração se aplique a apenas uma etapa, **Troque** por um agente diferente em vez de editar o agente compartilhado.
</Warning>
## Relacionados
<CardGroup cols={2}>
<Card title="Crew Studio" href="/pt-BR/enterprise/features/crew-studio" icon="pencil">
Crie automações com assistência de IA e um editor visual.
</Card>
<Card title="Repositórios de Agentes" href="/pt-BR/enterprise/features/agent-repositories" icon="users">
Gerencie e reutilize agentes em suas automações.
</Card>
</CardGroup>

View File

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

View File

@@ -1 +1 @@
__version__ = "1.14.7"
__version__ = "1.14.8a2"

View File

@@ -89,13 +89,16 @@ description = "{name} using crewAI"
authors = [{{ name = "Your Name", email = "you@example.com" }}]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]>=1.14.7"
"crewai[tools]==1.14.8a1"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
[tool.crewai]
type = "crew"
"""
@@ -677,7 +680,7 @@ def _default_agents_and_tasks(
]
crew_settings = {
"process": "sequential",
"memory": False,
"memory": True,
"inputs": {},
}
return agents, tasks, crew_settings

View File

@@ -34,6 +34,25 @@ _C_MUTED = "#666666" # dimmer than _C_DIM for past timeline
_STEP_NUMBER_RE = re.compile(r"\bstep\s+(\d+)\b", re.IGNORECASE)
_REFINEMENT_RE = re.compile(r"^\s*step\s+(\d+)\s*:\s*(.+)\s*$", re.IGNORECASE)
_INTERNAL_TOOL_NAMES = {"create_reasoning_plan"}
_LOG_ARGS_TEXT_LIMIT = 3_000
_LOG_RESULT_TEXT_LIMIT = 5_000
_LOG_TRUNCATION_SUFFIX = "... [truncated]"
# Background memory saves can emit their start event just after kickoff returns.
_MEMORY_SAVE_DRAIN_GRACE_SECONDS = 2.0
def _is_save_to_memory_tool(tool_name: str | None) -> bool:
return (tool_name or "").replace(" ", "_").lower() == "save_to_memory"
def _truncate_log_text(value: Any, limit: int) -> str | None:
if value is None:
return None
text = str(value)
if len(text) <= limit:
return text
suffix = _LOG_TRUNCATION_SUFFIX
return f"{text[: max(0, limit - len(suffix))]}{suffix}"
def _enable_tracing_in_dotenv() -> None:
@@ -519,6 +538,8 @@ FooterKey .footer-key--key {
self._log_expanded: set[int] = set()
self._log_scroll_needed: bool = False
self._log_line_map: list[tuple[int, int, int]] = []
self._suppressed_memory_save_event_ids: set[str] = set()
self._memory_save_drain_timer: Any = None
self._event_handlers: list[tuple[type, Any]] = []
@@ -633,7 +654,6 @@ FooterKey .footer-key--key {
self.call_from_thread(self._on_crew_failed, str(e))
def _on_crew_done(self, output: str | None) -> None:
self._unsubscribe()
with self._lock:
self._status = "completed"
self._final_output = output
@@ -649,6 +669,8 @@ FooterKey .footer-key--key {
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "timeout"
entry["error"] = "No result received before crew completed"
entry["duration"] = now - entry["start_time"]
@@ -680,9 +702,9 @@ FooterKey .footer-key--key {
self.call_later(self._focus_activity_log)
self._tick_timer.stop()
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
def _on_crew_failed(self, error: str) -> None:
self._unsubscribe()
with self._lock:
self._status = "failed"
self._error = error
@@ -692,12 +714,16 @@ FooterKey .footer-key--key {
now = time.time()
for entry in self._log_entries:
if entry["status"] == "running":
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "error"
entry["error"] = "No result received before crew failed"
entry["duration"] = now - entry["start_time"]
self._tick()
self.call_later(self._focus_activity_log)
self._tick_timer.stop()
self._tick_timer = self.set_interval(1 / 2, self._tick)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
# ── Actions ─────────────────────────────────────────────
@@ -1514,6 +1540,53 @@ FooterKey .footer-key--key {
pass
self._event_handlers.clear()
def _has_running_memory_save_locked(self) -> bool:
return any(
entry["tool_name"] == "memory_save" and entry["status"] == "running"
for entry in self._log_entries
)
def _on_memory_save_drain_elapsed(self) -> None:
self._memory_save_drain_timer = None
self._unsubscribe_if_no_running_memory_save()
def _schedule_memory_save_drain_unsubscribe(self) -> bool:
loop = getattr(self, "_loop", None)
if loop is None:
return False
if getattr(self, "_thread_id", None) != threading.get_ident():
try:
loop.call_soon_threadsafe(self._schedule_memory_save_drain_unsubscribe)
except RuntimeError:
return False
return True
if self._memory_save_drain_timer is not None:
self._memory_save_drain_timer.stop()
self._memory_save_drain_timer = self.set_timer(
_MEMORY_SAVE_DRAIN_GRACE_SECONDS,
self._on_memory_save_drain_elapsed,
name="memory-save-drain",
)
return True
def _unsubscribe_if_no_running_memory_save(
self, *, wait_for_queued: bool = False
) -> None:
with self._lock:
should_unsubscribe = (
self._status
in {
"completed",
"failed",
}
and not self._has_running_memory_save_locked()
)
if should_unsubscribe:
if wait_for_queued and self._schedule_memory_save_drain_unsubscribe():
return
self._unsubscribe()
def _subscribe(self) -> None:
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import CrewKickoffStartedEvent
@@ -1802,6 +1875,8 @@ FooterKey .footer-key--key {
entry["status"] == "running"
and entry["tool_name"] != event.tool_name
):
if entry["tool_name"] == "memory_save":
continue
entry["status"] = "timeout"
entry["error"] = (
"No result received before the next tool started"
@@ -1830,6 +1905,7 @@ FooterKey .footer-key--key {
"duration": None,
"task_idx": self._current_task_idx,
"plan_step_number": plan_step_number,
"event_id": event.event_id,
}
)
self._complete_step("teal", f"{event.tool_name}")
@@ -1923,8 +1999,178 @@ FooterKey .footer-key--key {
MemoryRetrievalCompletedEvent,
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
def is_nested_save_to_memory_event(event: Any) -> bool:
if event.parent_event_id is None:
return False
state = crewai_event_bus.runtime_state
if state is None:
return False
parent_node = state.event_record.nodes.get(event.parent_event_id)
parent_event = getattr(parent_node, "event", None)
return getattr(
parent_event, "type", None
) == "tool_usage_started" and _is_save_to_memory_tool(
getattr(parent_event, "tool_name", None)
)
@crewai_event_bus.on(MemorySaveStartedEvent)
def on_memory_save_started(source: Any, event: MemorySaveStartedEvent) -> None:
with self._lock:
if is_nested_save_to_memory_event(event):
self._suppressed_memory_save_event_ids.add(event.event_id)
return
for entry in reversed(self._log_entries):
if (
_is_save_to_memory_tool(entry["tool_name"])
and entry.get("event_id") == event.parent_event_id
):
self._suppressed_memory_save_event_ids.add(event.event_id)
return
for entry in reversed(self._log_entries):
if (
entry["tool_name"] == "memory_save"
and entry.get("started_event_id") == event.event_id
):
entry["args"] = _truncate_log_text(
event.value, _LOG_ARGS_TEXT_LIMIT
)
return
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "running",
"args": _truncate_log_text(event.value, _LOG_ARGS_TEXT_LIMIT),
"result": None,
"error": None,
"start_time": time.time(),
"duration": None,
"task_idx": self._current_task_idx,
"event_id": event.event_id,
}
)
self._register_handler(MemorySaveStartedEvent, on_memory_save_started)
@crewai_event_bus.on(MemorySaveCompletedEvent)
def on_memory_save_completed(
source: Any, event: MemorySaveCompletedEvent
) -> None:
with self._lock:
if (
event.started_event_id in self._suppressed_memory_save_event_ids
or is_nested_save_to_memory_event(event)
):
if event.started_event_id is not None:
self._suppressed_memory_save_event_ids.discard(
event.started_event_id
)
else:
for entry in reversed(self._log_entries):
has_started_event_match = (
event.started_event_id is not None
and (
entry.get("event_id") == event.started_event_id
or entry.get("started_event_id")
== event.started_event_id
)
)
has_running_event_without_id = (
event.started_event_id is None
and entry["status"] == "running"
)
if entry["tool_name"] == "memory_save" and (
has_running_event_without_id or has_started_event_match
):
entry["status"] = "success"
entry["duration"] = event.save_time_ms / 1000
entry["result"] = _truncate_log_text(
event.value, _LOG_RESULT_TEXT_LIMIT
)
entry["error"] = None
entry["started_event_id"] = event.started_event_id
break
else:
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "success",
"args": None,
"result": _truncate_log_text(
event.value, _LOG_RESULT_TEXT_LIMIT
),
"error": None,
"start_time": time.time(),
"duration": event.save_time_ms / 1000,
"task_idx": self._current_task_idx,
"started_event_id": event.started_event_id,
}
)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
self._register_handler(MemorySaveCompletedEvent, on_memory_save_completed)
@crewai_event_bus.on(MemorySaveFailedEvent)
def on_memory_save_failed(source: Any, event: MemorySaveFailedEvent) -> None:
with self._lock:
if (
event.started_event_id in self._suppressed_memory_save_event_ids
or is_nested_save_to_memory_event(event)
):
if event.started_event_id is not None:
self._suppressed_memory_save_event_ids.discard(
event.started_event_id
)
else:
for idx, entry in reversed(list(enumerate(self._log_entries))):
has_started_event_match = (
event.started_event_id is not None
and (
entry.get("event_id") == event.started_event_id
or entry.get("started_event_id")
== event.started_event_id
)
)
has_running_event_without_id = (
event.started_event_id is None
and entry["status"] == "running"
)
if entry["tool_name"] == "memory_save" and (
has_running_event_without_id or has_started_event_match
):
entry["status"] = "error"
entry["error"] = event.error
entry["duration"] = time.time() - entry["start_time"]
entry["started_event_id"] = event.started_event_id
self._log_expanded.add(idx)
break
else:
self._log_entries.append(
{
"tool_name": "memory_save",
"status": "error",
"args": _truncate_log_text(
event.value, _LOG_ARGS_TEXT_LIMIT
),
"result": None,
"error": event.error,
"start_time": time.time(),
"duration": 0,
"task_idx": self._current_task_idx,
"started_event_id": event.started_event_id,
}
)
self._log_expanded.add(len(self._log_entries) - 1)
self._unsubscribe_if_no_running_memory_save(wait_for_queued=True)
self._register_handler(MemorySaveFailedEvent, on_memory_save_failed)
@crewai_event_bus.on(MemoryRetrievalStartedEvent)
def on_memory_retrieval_started(
source: Any, event: MemoryRetrievalStartedEvent

View File

@@ -1,15 +1,11 @@
from __future__ import annotations
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any
import zipfile
from crewai_cli import git
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import parse_toml
_EXCLUDED_DIRS = {
@@ -38,8 +34,6 @@ _EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
}
_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
def create_project_zip(
@@ -143,267 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path:
destination = staging_root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
if _is_json_crew_project(staging_root):
_add_json_crew_deploy_wrapper(staging_root)
except Exception:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return staging_root
def _is_json_crew_project(root: Path) -> bool:
"""Return True for JSON crew projects that need a Python deploy wrapper."""
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
return False
project = _read_pyproject(root)
tool_config = project.get("tool") or {}
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
declared_type = (
crewai_config.get("type") if isinstance(crewai_config, dict) else None
)
if declared_type == "flow":
return False
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
return not (root / "src" / package_name / "crew.py").is_file()
def _read_pyproject(root: Path) -> dict[str, Any]:
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return {}
try:
pyproject = parse_toml(pyproject_path.read_text())
except Exception:
return {}
return pyproject if isinstance(pyproject, dict) else {}
def _package_name(root: Path) -> str | None:
"""Return the normalized Python package name for the project."""
project = _read_pyproject(root).get("project")
if not isinstance(project, dict):
return None
name = project.get("name")
if not isinstance(name, str) or not name.strip():
return None
package_name = normalize_package_name(name)
return package_name or None
def _class_name(package_name: str) -> str:
"""Return the generated wrapper class name for a package."""
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
if not class_name:
return "JsonCrew"
if class_name[0].isdigit():
return f"Crew{class_name}"
return class_name
def _add_json_crew_deploy_wrapper(root: Path) -> None:
"""Add Python wrapper files required to deploy a JSON crew project."""
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
package_dir = root / "src" / package_name
config_dir = package_dir / "config"
config_dir.mkdir(parents=True, exist_ok=True)
class_name = _class_name(package_name)
crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
(config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
(package_dir / "crew.py").write_text(
_json_crew_py(class_name, crew_filename),
encoding="utf-8",
)
(package_dir / "main.py").write_text(
_json_main_py(package_name, class_name),
encoding="utf-8",
)
_ensure_project_scripts(root, package_name)
def _json_crew_py(class_name: str, crew_filename: str) -> str:
"""Render the generated crew.py module for a JSON crew."""
return f'''from pathlib import Path
from crewai import Crew
from crewai.project import CrewBase, crew
from crewai.project.crew_loader import load_crew
def _crew_path() -> Path:
return Path(__file__).resolve().parents[2] / "{crew_filename}"
@CrewBase
class {class_name}:
"""Compatibility wrapper for a JSON-defined CrewAI project."""
@crew
def crew(self) -> Crew:
crew_instance, default_inputs = load_crew(_crew_path())
self.default_inputs = default_inputs
return crew_instance
'''
def _json_main_py(package_name: str, class_name: str) -> str:
"""Render the generated main.py entrypoints for a JSON crew."""
return f"""#!/usr/bin/env python
import json
import sys
from {package_name}.crew import {class_name}
def _load():
wrapper = {class_name}()
crew = wrapper.crew()
return crew, getattr(wrapper, "default_inputs", {{}})
def run():
crew, inputs = _load()
return crew.kickoff(inputs=inputs)
def train():
crew, inputs = _load()
return crew.train(
n_iterations=int(sys.argv[1]),
filename=sys.argv[2],
inputs=inputs,
)
def replay():
crew, _ = _load()
return crew.replay(task_id=sys.argv[1])
def test():
crew, inputs = _load()
return crew.test(
n_iterations=int(sys.argv[1]),
eval_llm=sys.argv[2],
inputs=inputs,
)
def run_with_trigger():
if len(sys.argv) < 2:
raise ValueError("No trigger payload provided.")
crew, inputs = _load()
trigger_payload = json.loads(sys.argv[1])
return crew.kickoff(
inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
)
"""
def _ensure_project_scripts(root: Path, package_name: str) -> None:
"""Ensure generated wrappers have project script entrypoints."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return
content = pyproject_path.read_text(encoding="utf-8")
entries = _project_script_entries(package_name)
pyproject_path.write_text(
_update_project_scripts(content, entries),
encoding="utf-8",
)
def _project_script_entries(package_name: str) -> dict[str, str]:
"""Return script entrypoints required by the generated JSON wrapper."""
return {
package_name: f"{package_name}.main:run",
"run_crew": f"{package_name}.main:run",
"train": f"{package_name}.main:train",
"replay": f"{package_name}.main:replay",
"test": f"{package_name}.main:test",
"run_with_trigger": f"{package_name}.main:run_with_trigger",
}
def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
"""Add or replace generated script entries in pyproject.toml content."""
lines = content.rstrip().splitlines()
header_index = _project_scripts_header_index(lines)
if header_index is None:
return content.rstrip() + _project_scripts_block(entries)
end_index = _section_end_index(lines, header_index + 1)
seen: set[str] = set()
for index in range(header_index + 1, end_index):
key = _script_key(lines[index])
if key in entries:
lines[index] = _script_line(key, entries[key])
seen.add(key)
missing_lines = [
_script_line(key, value) for key, value in entries.items() if key not in seen
]
lines[end_index:end_index] = missing_lines
return "\n".join(lines).rstrip() + "\n"
def _project_scripts_header_index(lines: list[str]) -> int | None:
"""Return the line index of the project scripts table, if present."""
for index, line in enumerate(lines):
if line.strip() == "[project.scripts]":
return index
return None
def _section_end_index(lines: list[str], start_index: int) -> int:
"""Return the exclusive end index for a TOML table section."""
for index in range(start_index, len(lines)):
if _SECTION_PATTERN.match(lines[index]):
return index
return len(lines)
def _script_key(line: str) -> str | None:
"""Return the script key for a pyproject script line."""
match = _SCRIPT_KEY_PATTERN.match(line)
if not match:
return None
key = match.group("key")
if key.startswith(("'", '"')) and key.endswith(("'", '"')):
return key[1:-1]
return key
def _script_line(key: str, value: str) -> str:
"""Render a project script TOML entry."""
return f'{key} = "{value}"'
def _project_scripts_block(entries: dict[str, str]) -> str:
"""Render a project scripts TOML table."""
lines = ["", "", "[project.scripts]"]
lines.extend(_script_line(key, value) for key, value in entries.items())
return "\n".join(lines) + "\n"

View File

@@ -212,8 +212,16 @@ class DeployValidator:
if crew_path is None:
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
agents_dir_ok = self._check_json_agents_dir(agents_dir)
project = None
try:
project = validate_crew_project(crew_path, self.project_root / "agents")
if agents_dir_ok:
project = validate_crew_project(crew_path, agents_dir)
except JSONProjectValidationError as e:
self._add(
Severity.ERROR,
@@ -232,15 +240,27 @@ class DeployValidator:
)
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
if project is not None:
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
self._check_version_vs_lockfile()
return self.results
def _check_json_agents_dir(self, agents_dir: Path) -> bool:
if agents_dir.is_dir():
return True
self._add(
Severity.ERROR,
"missing_agents_dir",
"Cannot find agents/ directory",
detail=(
"JSON crew projects load agent definitions from "
f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json."
),
hint="Create agents/ and add one JSON or JSONC file per agent.",
)
return False
def _check_env_vars_json(
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
) -> None:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
@@ -7,10 +8,9 @@ from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import click
from crewai.project.json_loader import find_crew_json_file
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
from packaging import version
@@ -38,6 +38,15 @@ class CrewType(Enum):
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
_FULL_CREWAI_INSTALL_MESSAGE = """\
CrewAI CLI is installed without the `crewai` package required to run crews.
Install the full CrewAI prerelease package:
uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'
The quotes are required in zsh so `crewai[tools]` is not treated as a glob.
"""
_JSON_CREW_RUNNER_CODE = """
import importlib.util
import os
@@ -72,12 +81,39 @@ module_spec.loader.exec_module(module)
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
try:
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
except module.click.ClickException as exc:
exc.show()
raise SystemExit(exc.exit_code)
""".strip()
def _import_find_crew_json_file() -> Callable[[], Path | None]:
from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file
return cast("Callable[[], Path | None]", _find_crew_json_file)
def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool:
return bool(exc.name and exc.name.startswith("crewai"))
def _full_crewai_install_error() -> click.ClickException:
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
def find_crew_json_file() -> Path | None:
try:
return _import_find_crew_json_file()()
except ModuleNotFoundError as exc:
if _is_missing_crewai_package(exc):
raise _full_crewai_install_error() from exc
raise
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7"
"crewai[tools]==1.14.8a2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7"
"crewai[tools]==1.14.8a2"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7"
"crewai[tools]==1.14.8a2"
]
[tool.crewai]

View File

@@ -132,7 +132,7 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
assert names == {"pyproject.toml"}
def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
def test_create_project_zip_preserves_json_project_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -157,8 +157,6 @@ type = "crew"
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
crew_py = archive.read("src/json_crew/crew.py").decode()
main_py = archive.read("src/json_crew/main.py").decode()
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
@@ -166,18 +164,44 @@ type = "crew"
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
assert "src/json_crew/__init__.py" in names
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "src/json_crew/config/agents.yaml" in names
assert "src/json_crew/config/tasks.yaml" in names
assert "load_crew(_crew_path())" in crew_py
assert "JsonCrew" in crew_py
assert "from json_crew.crew import JsonCrew" in main_py
assert "run_crew = \"json_crew.main:run\"" in pyproject
assert all(not name.startswith("src/") for name in names)
assert "run_crew" not in pyproject
def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]==1.14.8a1"]
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "foo.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"agents/foo.jsonc",
"crew.jsonc",
"pyproject.toml",
"uv.lock",
}
def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -203,14 +227,9 @@ type = "crew"
finally:
archive_path.unlink(missing_ok=True)
assert 'json_crew = "json_crew.main:run"' in pyproject
assert 'run_crew = "json_crew.main:run"' in pyproject
assert 'train = "json_crew.main:train"' in pyproject
assert 'replay = "json_crew.main:replay"' in pyproject
assert 'test = "json_crew.main:test"' in pyproject
assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
assert 'json_crew = "old.module:run"' in pyproject
assert 'run_crew = "old.module:run"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
assert "old.module:run" not in pyproject
assert "[tool.crewai]" in pyproject
@@ -221,7 +240,7 @@ type = "crew"
'[tool]\ncrewai = "invalid"\n',
],
)
def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
def test_create_project_zip_preserves_json_project_with_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
@@ -244,12 +263,11 @@ version = "0.1.0"
finally:
archive_path.unlink(missing_ok=True)
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "run_crew = \"json_crew.main:run\"" in pyproject
assert names == {"crew.jsonc", "pyproject.toml"}
assert "run_crew" not in pyproject
def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
@@ -263,8 +281,11 @@ type = "crew"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
with pytest.raises(
ValueError,
match=r"Could not derive a valid Python package name",
):
create_project_zip("invalid", project_dir=tmp_path)
archive_path = create_project_zip("invalid", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {"crew.jsonc", "pyproject.toml"}

View File

@@ -200,6 +200,41 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
assert "runtime-only" in finding.detail
def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
for path in (tmp_path / "agents").iterdir():
path.unlink()
(tmp_path / "agents").rmdir()
v = DeployValidator(project_root=tmp_path)
v.run()
codes = _codes(v)
assert "missing_agents_dir" in codes
assert "missing_src_dir" not in codes
assert "missing_crew_py" not in codes
assert "missing_agents_yaml" not in codes
assert "missing_tasks_yaml" not in codes
def test_json_crew_reports_project_metadata_before_invalid_json(
tmp_path: Path,
) -> None:
_scaffold_json_crew(tmp_path)
(tmp_path / "pyproject.toml").unlink()
(tmp_path / "uv.lock").unlink()
(tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n')
v = DeployValidator(project_root=tmp_path)
v.run()
codes = _codes(v)
assert "missing_pyproject" in codes
assert "missing_lockfile" in codes
assert "invalid_crew_json" in codes
assert "missing_src_dir" not in codes
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)

View File

@@ -5,7 +5,10 @@ from pathlib import Path
from unittest import mock
import pytest
import tomli
from click.testing import CliRunner
from packaging.requirements import Requirement
from packaging.version import Version
import crewai_cli.create_json_crew as json_crew
import crewai_cli.tui_picker as tui_picker
from crewai_cli.create_crew import create_crew, create_folder_structure
@@ -709,8 +712,34 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
default_llm="openai/gpt-5.5",
)
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
assert not (tmp_path / "json_crew" / "src").exists()
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
generated_paths = {
path.relative_to(tmp_path / "json_crew").as_posix()
for path in (tmp_path / "json_crew").rglob("*")
if path.is_file()
}
assert not any(
path.endswith("/crew.py") or path == "crew.py" for path in generated_paths
)
assert not any(
path.endswith("/agents.yaml") or path == "agents.yaml"
for path in generated_paths
)
assert not any(
path.endswith("/tasks.yaml") or path == "tasks.yaml"
for path in generated_paths
)
assert not any(path.startswith("src/") for path in generated_paths)
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
dependency = pyproject["project"]["dependencies"][0]
assert dependency == "crewai[tools]==1.14.8a1"
assert Version("1.14.8a1") in Requirement(dependency).specifier
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"only-include"
] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
assert (
@@ -838,7 +867,7 @@ def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatc
crew_template = (project_root / "crew.jsonc").read_text()
agent_template = (project_root / "agents" / "researcher.jsonc").read_text()
assert '"memory": false' in crew_template
assert '"memory": true' in crew_template
assert '"description": "Research current AI trends and write a concise summary."' in (
crew_template
)

View File

@@ -4,6 +4,11 @@ import time
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.observation_events import (
GoalAchievedEarlyEvent,
PlanRefinementEvent,
@@ -21,7 +26,12 @@ from crewai.events.types.tool_usage_events import (
)
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli import run_crew
from crewai_cli.crew_run_tui import CrewRunApp
from crewai_cli.crew_run_tui import (
CrewRunApp,
_LOG_ARGS_TEXT_LIMIT,
_LOG_RESULT_TEXT_LIMIT,
_LOG_TRUNCATION_SUFFIX,
)
def _app_with_plan() -> CrewRunApp:
@@ -335,6 +345,396 @@ def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
assert app._current_task_steps == []
def test_memory_save_events_are_shown_in_activity_log() -> None:
app = _app_with_plan()
app._current_task_idx = 1
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="2 memories (background)",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="2 memories saved",
metadata={},
save_time_ms=123,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["args"] == "2 memories (background)"
assert app._log_entries[0]["result"] == "2 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 0.123
assert app._log_entries[0]["task_idx"] == 1
def test_nested_memory_save_event_is_hidden_for_save_to_memory_tool() -> None:
app = _app_with_plan()
app._subscribe()
try:
tool_args = {"contents": ["Fact to remember."]}
_emit_event(
ToolUsageStartedEvent(
tool_name="save_to_memory",
tool_args=tool_args,
)
)
_emit_event(
MemorySaveStartedEvent(
value="Fact to remember.",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="Fact to remember.",
metadata={},
save_time_ms=123,
source_type="unified_memory",
)
)
now = datetime.now()
_emit_event(
ToolUsageFinishedEvent(
tool_name="save_to_memory",
tool_args=tool_args,
started_at=now,
finished_at=now,
output="Saved to memory.",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "save_to_memory"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "Saved to memory."
def test_memory_save_failure_is_shown_in_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="background save",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveFailedEvent(
value="background save",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "error"
assert app._log_entries[0]["error"] == "embedding connection failed"
assert app._log_expanded == {0}
def test_memory_save_completion_updates_timed_out_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
)
app._log_entries[0]["status"] = "timeout"
app._log_entries[0]["error"] = "No result received before crew completed"
app._log_entries[0]["duration"] = 8.3
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries) == 1
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
def test_memory_save_completion_with_unmatched_id_does_not_update_running_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="first background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveStartedEvent(
value="second background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveCompletedEvent(
value="orphan save completed",
metadata={},
save_time_ms=2800,
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id="missing-memory-save-start",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"running",
"running",
"success",
]
assert app._log_entries[0]["args"] == "first background save"
assert app._log_entries[1]["args"] == "second background save"
assert app._log_entries[2]["result"] == "orphan save completed"
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
def test_memory_save_failure_with_unmatched_id_does_not_update_running_row() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="first background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveStartedEvent(
value="second background save",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
_emit_event(
MemorySaveFailedEvent(
value="orphan save failed",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id="missing-memory-save-start",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"running",
"running",
"error",
]
assert app._log_entries[0]["args"] == "first background save"
assert app._log_entries[1]["args"] == "second background save"
assert app._log_entries[2]["args"] == "orphan save failed"
assert app._log_entries[2]["error"] == "embedding connection failed"
assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start"
assert app._log_expanded == {2}
def test_memory_save_completion_without_id_does_not_update_stale_row() -> None:
app = _app_with_plan()
now = time.time()
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "current background save",
"result": None,
"error": None,
"start_time": now,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "memory_save",
"status": "success",
"args": "stale background save",
"result": "stale save completed",
"error": None,
"start_time": now - 10,
"duration": 1.0,
"task_idx": 1,
},
]
app._subscribe()
try:
_emit_event(
MemorySaveCompletedEvent(
value="current save completed",
metadata={},
save_time_ms=2800,
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == [
"success",
"success",
]
assert app._log_entries[0]["args"] == "current background save"
assert app._log_entries[0]["result"] == "current save completed"
assert app._log_entries[1]["args"] == "stale background save"
assert app._log_entries[1]["result"] == "stale save completed"
def test_memory_save_failure_without_id_does_not_update_stale_row() -> None:
app = _app_with_plan()
now = time.time()
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "current background save",
"result": None,
"error": None,
"start_time": now,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "memory_save",
"status": "success",
"args": "stale background save",
"result": "stale save completed",
"error": None,
"start_time": now - 10,
"duration": 1.0,
"task_idx": 1,
},
]
app._subscribe()
try:
_emit_event(
MemorySaveFailedEvent(
value="current save failed",
metadata={},
error="embedding connection failed",
source_type="unified_memory",
parent_event_id="manual-parent",
)
)
finally:
app._unsubscribe()
assert [entry["status"] for entry in app._log_entries] == ["error", "success"]
assert app._log_entries[0]["args"] == "current background save"
assert app._log_entries[0]["error"] == "embedding connection failed"
assert app._log_entries[1]["args"] == "stale background save"
assert app._log_entries[1]["result"] == "stale save completed"
assert app._log_entries[1]["error"] is None
assert app._log_expanded == {0}
def test_memory_save_payloads_are_truncated_in_activity_log() -> None:
app = _app_with_plan()
long_args = "a" * (_LOG_ARGS_TEXT_LIMIT + 10)
long_result = "r" * (_LOG_RESULT_TEXT_LIMIT + 10)
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value=long_args,
metadata={},
source_type="unified_memory",
)
)
_emit_event(
MemorySaveCompletedEvent(
value=long_result,
metadata={},
save_time_ms=8300,
source_type="unified_memory",
)
)
finally:
app._unsubscribe()
assert len(app._log_entries[0]["args"]) == _LOG_ARGS_TEXT_LIMIT
assert app._log_entries[0]["args"].endswith(_LOG_TRUNCATION_SUFFIX)
assert len(app._log_entries[0]["result"]) == _LOG_RESULT_TEXT_LIMIT
assert app._log_entries[0]["result"].endswith(_LOG_TRUNCATION_SUFFIX)
def test_starting_next_tool_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="read_website_content",
tool_args={"url": "https://example.com"},
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["tool_name"] == "read_website_content"
assert app._log_entries[1]["status"] == "running"
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
app = _app_with_plan()
app._subscribe()
@@ -480,6 +880,187 @@ async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
@pytest.mark.asyncio
async def test_crew_done_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "9 memories (background)",
"result": None,
"error": None,
"start_time": time.time() - 8,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
},
]
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["status"] == "timeout"
assert app._log_entries[1]["error"] == "No result received before crew completed"
@pytest.mark.asyncio
async def test_crew_done_keeps_memory_save_subscription_until_completion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
)
app = _app_with_plan()
auto_unsubscribed = False
async with app.run_test(size=(100, 40)) as pilot:
try:
assert app._event_handlers
started_event = MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
)
_emit_event(started_event)
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._event_handlers
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
started_event_id=started_event.event_id,
)
)
await pilot.pause()
assert app._event_handlers
await pilot.pause(0.08)
auto_unsubscribed = not app._event_handlers
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
assert auto_unsubscribed is True
@pytest.mark.asyncio
async def test_crew_done_waits_for_queued_memory_save_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05
)
app = _app_with_plan()
auto_unsubscribed = False
async with app.run_test(size=(100, 40)) as pilot:
try:
assert app._event_handlers
app._on_crew_done("final output")
assert app._event_handlers
started_event = MemorySaveStartedEvent(
value="9 memories (background)",
metadata={},
source_type="unified_memory",
parent_event_id="manual-parent",
)
_emit_event(started_event)
await pilot.pause()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "running"
_emit_event(
MemorySaveCompletedEvent(
value="9 memories saved",
metadata={},
save_time_ms=8300,
source_type="unified_memory",
parent_event_id="manual-parent",
started_event_id=started_event.event_id,
)
)
await pilot.pause()
assert app._event_handlers
await pilot.pause(0.08)
auto_unsubscribed = not app._event_handlers
finally:
app._unsubscribe()
assert app._log_entries[0]["tool_name"] == "memory_save"
assert app._log_entries[0]["status"] == "success"
assert app._log_entries[0]["args"] == "9 memories (background)"
assert app._log_entries[0]["result"] == "9 memories saved"
assert app._log_entries[0]["error"] is None
assert app._log_entries[0]["duration"] == 8.3
assert auto_unsubscribed is True
@pytest.mark.asyncio
async def test_crew_failed_does_not_timeout_memory_save() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [
{
"tool_name": "memory_save",
"status": "running",
"args": "9 memories (background)",
"result": None,
"error": None,
"start_time": time.time() - 8,
"duration": None,
"task_idx": 1,
},
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
},
]
app._on_crew_failed("boom")
await pilot.pause()
assert app._log_entries[0]["status"] == "running"
assert app._log_entries[0]["error"] is None
assert app._log_entries[1]["status"] == "error"
assert app._log_entries[1]["error"] == "No result received before crew failed"
def test_streamed_step_observation_updates_named_step_only() -> None:
app = _app_with_plan()

View File

@@ -5,12 +5,33 @@ from pathlib import Path
import subprocess
import sys
import click
import pytest
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
import crewai_cli.run_crew as run_crew_module
def test_missing_crewai_package_shows_full_install_hint(monkeypatch):
def missing_crewai_package():
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
monkeypatch.setattr(
run_crew_module, "_import_find_crew_json_file", missing_crewai_package
)
with pytest.raises(click.ClickException) as exc_info:
run_crew_module.find_crew_json_file()
message = exc_info.value.message
assert "CrewAI CLI is installed without the `crewai` package" in message
assert (
"uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'"
in message
)
assert "quotes are required in zsh" in message
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)

View File

@@ -1 +1 @@
__version__ = "1.14.7"
__version__ = "1.14.8a2"

View File

@@ -9,7 +9,7 @@ authors = [
requires-python = ">=3.10, <3.14"
dependencies = [
"Pillow~=12.1.1",
"pypdf~=6.10.0",
"pypdf~=6.13.3",
"python-magic>=0.4.27",
"aiocache~=0.12.3",
"aiofiles~=24.1.0",
@@ -19,6 +19,8 @@ dependencies = [
[tool.uv]
exclude-newer = "3 days"
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
[build-system]
requires = ["hatchling"]

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.7"
__version__ = "1.14.8a2"

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.7",
"crewai==1.14.8a2",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -131,7 +131,7 @@ postgresql = [
]
bedrock = [
"beautifulsoup4>=4.13.4",
"bedrock-agentcore>=0.1.0",
"bedrock-agentcore>=1.7.0,<1.8.0",
"playwright>=1.52.0",
"nest-asyncio>=1.6.0",
]

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.7"
__version__ = "1.14.8a2"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.7",
"crewai-cli==1.14.7",
"crewai-core==1.14.8a2",
"crewai-cli==1.14.8a2",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.7",
"crewai-tools==1.14.8a2",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -78,8 +78,8 @@ qdrant = [
"qdrant-client[fastembed]~=1.14.3",
]
aws = [
"boto3~=1.42.79",
"aiobotocore~=3.4.0",
"boto3~=1.42.90",
"aiobotocore~=3.5.0",
]
watson = [
"ibm-watsonx-ai~=1.3.39",
@@ -91,7 +91,7 @@ litellm = [
"litellm>=1.84.0,<2",
]
bedrock = [
"boto3~=1.42.79",
"boto3~=1.42.90",
]
google-genai = [
"google-genai~=1.65.0",

View File

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

View File

@@ -57,6 +57,7 @@ from crewai.utilities.agent_utils import (
convert_tools_to_openai_schema,
enforce_rpm_limit,
format_message_for_llm,
format_native_tool_output_for_agent,
get_llm_response,
handle_agent_action_core,
handle_context_length,
@@ -907,19 +908,31 @@ class CrewAgentExecutor(BaseAgentExecutor):
):
max_usage_reached = True
structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
output_tool = original_tool or structured_tool
from_cache = False
result: str = "Tool not found"
raw_tool_result: Any = result
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
@@ -938,18 +951,6 @@ class CrewAgentExecutor(BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict or {}, self.task)
structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -975,11 +976,18 @@ class CrewAgentExecutor(BaseAgentExecutor):
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
elif max_usage_reached and original_tool:
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
elif not from_cache and func_name in available_functions:
raw_tool_result = result
elif (
not from_cache
and func_name in available_functions
and output_tool is not None
):
try:
raw_result = available_functions[func_name](**(args_dict or {}))
raw_tool_result = raw_result
if self.tools_handler and self.tools_handler.cache:
should_cache = True
@@ -996,11 +1004,10 @@ class CrewAgentExecutor(BaseAgentExecutor):
tool=func_name, input=input_str, output=raw_result
)
result = (
str(raw_result) if not isinstance(raw_result, str) else raw_result
)
result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
crewai_event_bus.emit(
@@ -1024,6 +1031,7 @@ class CrewAgentExecutor(BaseAgentExecutor):
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from typing import Any
from pydantic import BaseModel, Field
@@ -25,14 +26,14 @@ class ToolsHandler(BaseModel):
def on_tool_use(
self,
calling: ToolCalling | InstructorToolCalling,
output: str,
output: Any,
should_cache: bool = True,
) -> None:
"""Run when tool ends running.
Args:
calling: The tool calling instance.
output: The output from the tool execution.
output: The raw output from the tool execution.
should_cache: Whether to cache the tool output.
"""
self.last_used_tool = calling

View File

@@ -80,6 +80,7 @@ from crewai.utilities.agent_utils import (
enforce_rpm_limit,
extract_tool_call_info,
format_message_for_llm,
format_native_tool_output_for_agent,
get_llm_response,
handle_agent_action_core,
handle_context_length,
@@ -1905,19 +1906,32 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
):
max_usage_reached = True
structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
output_tool = original_tool or structured_tool
# Check cache before executing
from_cache = False
result = "Tool not found"
raw_tool_result: Any = result
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache:
if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
tool=func_name, input=input_str
)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
# Emit tool usage started event
@@ -1936,18 +1950,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
track_delegation_if_needed(func_name, args_dict, self.task)
structured_tool: CrewStructuredTool | None = None
if original_tool is not None:
for structured in self.tools or []:
if getattr(structured, "_original_tool", None) is original_tool:
structured_tool = structured
break
if structured_tool is None:
for structured in self.tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -1973,12 +1975,13 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
elif not from_cache and not max_usage_reached:
result = "Tool not found"
raw_tool_result = result
elif not from_cache and not max_usage_reached and output_tool is not None:
if func_name in self._available_functions:
try:
tool_func = self._available_functions[func_name]
raw_result = tool_func(**args_dict)
raw_tool_result = raw_result
# Add to cache after successful execution (before string conversion)
if self.tools_handler and self.tools_handler.cache:
@@ -1992,14 +1995,12 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
tool=func_name, input=input_str, output=raw_result
)
# Convert to string for message
result = (
str(raw_result)
if not isinstance(raw_result, str)
else raw_result
result = format_native_tool_output_for_agent(
output_tool, raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
# Emit tool usage error event
@@ -2021,6 +2022,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
result = f"Tool '{func_name}' has reached its usage limit of {original_tool.max_usage_count} times and cannot be used anymore."
else:
result = f"Tool '{func_name}' has reached its maximum usage limit and cannot be used anymore."
raw_tool_result = result
# Execute after_tool_call hooks (even if blocked, to allow logging/monitoring)
after_hook_context = ToolCallHookContext(
@@ -2031,6 +2033,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
task=self.task,
crew=self.crew,
tool_result=result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
try:

View File

@@ -10,6 +10,7 @@ from crewai.flow.conversation import (
ConversationalInputs,
)
from crewai.flow.dsl import HumanFeedbackResult, human_feedback
from crewai.flow.expressions import Expression
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.flow_config import flow_config
from crewai.flow.input_provider import InputProvider, InputResponse
@@ -26,6 +27,7 @@ __all__ = [
"ConsoleProvider",
"ConversationalConfig",
"ConversationalInputs",
"Expression",
"Flow",
"FlowStructure",
"HumanFeedbackPending",

View File

@@ -14,7 +14,6 @@ from crewai.flow.flow_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
FlowDefinition,
FlowDefinitionDiagnostic,
FlowDictStateDefinition,
FlowHumanFeedbackDefinition,
FlowMethodDefinition,
@@ -23,6 +22,7 @@ from crewai.flow.flow_definition import (
FlowStateDefinition,
FlowUnknownStateDefinition,
_object_ref,
log_flow_definition_issues,
)
from crewai.flow.flow_wrappers import (
FlowMethod,
@@ -116,7 +116,6 @@ def _is_json_serializable(value: Any) -> bool:
def _serialize_static_value(
value: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> Any:
if value is None or _is_json_serializable(value):
@@ -148,12 +147,11 @@ def _serialize_static_value(
)
ref = _object_ref(value)
diagnostics.append(
FlowDefinitionDiagnostic(
code="non_serializable_value",
path=path,
message=f"value is not fully serializable; preserved import reference {ref}",
)
logger.warning(
"Flow definition value at %s is not fully serializable; "
"preserved import reference %s.",
path,
ref,
)
return {"ref": ref}
@@ -169,10 +167,7 @@ def _state_ref(value: Any) -> str | None:
return None
def _build_state_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowStateDefinition | None:
def _build_state_definition(flow_class: type) -> FlowStateDefinition | None:
from pydantic import BaseModel as PydanticBaseModel
state_value = getattr(flow_class, "_initial_state_t", None)
@@ -187,29 +182,23 @@ def _build_state_definition(
if state_value is dict or isinstance(state_value, dict):
default = None
if isinstance(state_value, dict):
default = _serialize_static_value(state_value, diagnostics, "state.default")
default = _serialize_static_value(state_value, "state.default")
return FlowDictStateDefinition(default=default)
if isinstance(state_value, type) and issubclass(state_value, PydanticBaseModel):
return FlowPydanticStateDefinition(ref=_state_ref(state_value))
if isinstance(state_value, PydanticBaseModel):
return FlowPydanticStateDefinition(
ref=_state_ref(state_value),
default=_serialize_static_value(state_value, diagnostics, "state.default"),
)
diagnostics.append(
FlowDefinitionDiagnostic(
code="unknown_state_type",
path="state",
message=f"could not serialize state type {_object_ref(state_value)}",
default=_serialize_static_value(state_value, "state.default"),
)
logger.warning(
"Flow definition state could not serialize state type %s.",
_object_ref(state_value),
)
return FlowUnknownStateDefinition(ref=_state_ref(state_value))
def _build_config_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConfigDefinition:
def _build_config_definition(flow_class: type) -> FlowConfigDefinition:
config_field_names = set(FlowConfigDefinition.model_fields)
field_defaults = {
name: field.get_default(call_default_factory=True)
@@ -225,15 +214,12 @@ def _build_config_definition(
value if value is None or isinstance(value, str) else _object_ref(value)
)
else:
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
values[field_name] = _serialize_static_value(value, f"config.{field_name}")
return FlowConfigDefinition(**values)
def _build_human_feedback_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowHumanFeedbackDefinition | None:
config = getattr(method, "__human_feedback_config__", None)
@@ -248,7 +234,7 @@ def _build_human_feedback_definition(
llm=getattr(config, "llm", None),
default_outcome=getattr(config, "default_outcome", None),
metadata=_serialize_static_value(
getattr(config, "metadata", None), diagnostics, f"{path}.metadata"
getattr(config, "metadata", None), f"{path}.metadata"
),
provider=getattr(config, "provider", None),
learn=bool(getattr(config, "learn", False)),
@@ -273,7 +259,6 @@ def _build_persistence_definition(value: Any) -> FlowPersistenceDefinition | Non
def _build_conversational_router_definition(
router_config: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowConversationalRouterDefinition | None:
if router_config is None:
@@ -284,12 +269,9 @@ def _build_conversational_router_definition(
prompt=getattr(router_config, "prompt", None),
response_format=_serialize_static_value(
getattr(router_config, "response_format", None),
diagnostics,
f"{path}.response_format",
),
llm=_serialize_static_value(
getattr(router_config, "llm", None), diagnostics, f"{path}.llm"
),
llm=_serialize_static_value(getattr(router_config, "llm", None), f"{path}.llm"),
routes=[str(route) for route in routes] if routes is not None else None,
route_descriptions=getattr(router_config, "route_descriptions", None),
default_intent=getattr(router_config, "default_intent", "converse"),
@@ -300,7 +282,6 @@ def _build_conversational_router_definition(
def _build_conversational_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConversationalDefinition | None:
if not _is_conversational_flow(flow_class):
return None
@@ -324,12 +305,9 @@ def _build_conversational_definition(
return FlowConversationalDefinition(
enabled=True,
system_prompt=getattr(config, "system_prompt", None),
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, "conversational.llm"
),
llm=_serialize_static_value(getattr(config, "llm", None), "conversational.llm"),
router=_build_conversational_router_definition(
getattr(config, "router", None),
diagnostics,
"conversational.router",
),
answer_from_history_prompt=getattr(config, "answer_from_history_prompt", None),
@@ -340,12 +318,10 @@ def _build_conversational_definition(
),
intent_llm=_serialize_static_value(
getattr(config, "intent_llm", None),
diagnostics,
"conversational.intent_llm",
),
answer_from_history_llm=_serialize_static_value(
getattr(config, "answer_from_history_llm", None),
diagnostics,
"conversational.answer_from_history_llm",
),
visible_agent_outputs=(
@@ -365,7 +341,6 @@ def _build_conversational_definition(
def _build_method_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowMethodDefinition:
fragment = _get_flow_method_definition(method)
@@ -376,9 +351,7 @@ def _build_method_definition(
deep=True, update={"do": _method_action(method)}
)
human_feedback = _build_human_feedback_definition(
method, diagnostics, f"{path}.human_feedback"
)
human_feedback = _build_human_feedback_definition(method, f"{path}.human_feedback")
if human_feedback is not None:
method_definition.human_feedback = human_feedback
if human_feedback.emit:
@@ -444,7 +417,6 @@ def _build_flow_definition_from_class(
flow_class: type,
namespace: dict[str, Any] | None = None,
) -> FlowDefinition:
diagnostics: list[FlowDefinitionDiagnostic] = []
methods: dict[str, FlowMethodDefinition] = {}
flow_methods = _iter_flow_methods(flow_class)
if namespace is not None:
@@ -456,7 +428,7 @@ def _build_flow_definition_from_class(
for method_name, method in flow_methods.items():
methods[method_name] = _build_method_definition(
method, diagnostics, f"methods.{method_name}"
method, f"methods.{method_name}"
)
description = None
@@ -467,15 +439,13 @@ def _build_flow_definition_from_class(
definition = FlowDefinition(
name=getattr(flow_class, "__name__", "Flow"),
description=description,
state=_build_state_definition(flow_class, diagnostics),
config=_build_config_definition(flow_class, diagnostics),
state=_build_state_definition(flow_class),
config=_build_config_definition(flow_class),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class, diagnostics),
conversational=_build_conversational_definition(flow_class),
methods=methods,
diagnostics=diagnostics,
)
definition.diagnostics.extend(definition.validate_contract())
definition.log_diagnostics()
log_flow_definition_issues(definition)
return definition

View File

@@ -0,0 +1,329 @@
"""Runtime expression support for FlowDefinition CEL expressions."""
from __future__ import annotations
from collections.abc import Iterable
import json
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
else:
from typing_extensions import TypeAliasType
_CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
{"all", "exists", "exists_one", "filter", "map"}
)
if TYPE_CHECKING:
ExpressionData: TypeAlias = (
str
| int
| float
| bool
| None
| list["ExpressionData"]
| dict[str, "ExpressionData"]
)
else:
ExpressionData = TypeAliasType(
"ExpressionData",
str
| int
| float
| bool
| None
| list["ExpressionData"]
| dict[str, "ExpressionData"],
)
__all__ = [
"Expression",
"ExpressionData",
"ExpressionError",
]
class ExpressionError(ValueError):
"""An expression failed to parse, validate, render, or evaluate."""
class Expression:
"""CEL expression helper used for definition-time checks and runtime rendering."""
def __init__(
self, value: ExpressionData, *, context: dict[str, Any] | None = None
) -> None:
self.value = value
self.context = context
@classmethod
def from_flow(
cls,
value: ExpressionData,
flow: Flow[Any],
*,
local_context: dict[str, Any] | None = None,
) -> Expression:
"""Build an expression with the standard Flow runtime context."""
return cls(value, context=cls._flow_context(flow, local_context=local_context))
def validate_expression(
self,
*,
allowed_roots: Iterable[str],
source: str = "CEL expression",
) -> None:
"""Validate a full CEL expression without evaluating it."""
allowed = frozenset(allowed_roots)
expression = self._require_cel_source(cast(str, self.value), source=source)
roots = self._collect_root_identifiers(
self._compile_cel(expression, source=source)
)
unknown = sorted(root for root in roots if root not in allowed)
if unknown:
allowed_list = ", ".join(sorted(allowed))
unknown_list = ", ".join(repr(root) for root in unknown)
raise ExpressionError(
f"unknown CEL root at {source}: {unknown_list}; "
f"allowed roots: {allowed_list}. Reference flow data through one "
"of those roots, for example state.field or outputs.step_name."
)
def validate_template(
self,
*,
allowed_roots: Iterable[str],
source: str = "with block",
) -> None:
"""Validate nested strings fully wrapped in ``${...}`` as CEL."""
self._validate_template_value(
self.value, allowed_roots=allowed_roots, source=source
)
def evaluate(self, context: dict[str, Any] | None = None) -> Any:
"""Evaluate this value as a full CEL expression."""
resolved_context = self.context if context is None else context
return self._evaluate_cel(
self._require_cel_source(cast(str, self.value)),
resolved_context or {},
)
def render_template(self, context: dict[str, Any] | None = None) -> Any:
"""Evaluate nested strings fully wrapped in ``${...}`` as CEL."""
resolved_context = self.context if context is None else context
return self._render_template_value(self.value, resolved_context or {})
@staticmethod
def _validate_template_value(
value: ExpressionData,
*,
allowed_roots: Iterable[str],
source: str,
) -> None:
if isinstance(value, str):
expression = Expression._expression_marker_source(value, source=source)
if expression is not None:
Expression(expression).validate_expression(
allowed_roots=allowed_roots, source=source
)
return
if isinstance(value, dict):
for key, item in value.items():
item_source = f"{source}.{key}" if isinstance(key, str) else source
Expression._validate_template_value(
item, allowed_roots=allowed_roots, source=item_source
)
return
if isinstance(value, list):
for index, item in enumerate(value):
Expression._validate_template_value(
item,
allowed_roots=allowed_roots,
source=f"{source}[{index}]",
)
@staticmethod
def _flow_context(
flow: Flow[Any], local_context: dict[str, Any] | None = None
) -> dict[str, Any]:
from crewai.flow.runtime._outputs import outputs_by_name
local_outputs = local_context.get("outputs") if local_context else None
outputs = outputs_by_name(
flow._method_outputs,
local_outputs=local_outputs,
serialize=True,
)
context: dict[str, Any] = {
"state": flow._copy_and_serialize_state(),
"outputs": outputs,
}
if local_context:
context.update(
{
key: to_serializable(value, max_depth=0)
for key, value in local_context.items()
if key not in {"outputs", "state"}
}
)
return context
@staticmethod
def _render_template_value(value: ExpressionData, context: dict[str, Any]) -> Any:
if isinstance(value, str):
return Expression._render_template_string(value, context)
if isinstance(value, dict):
return {
key: Expression._render_template_value(item, context)
for key, item in value.items()
}
if isinstance(value, list):
return [Expression._render_template_value(item, context) for item in value]
return value
@staticmethod
def _render_template_string(value: str, context: dict[str, Any]) -> Any:
expression = Expression._expression_marker_source(value)
if expression is None:
return value
return Expression._evaluate_cel(expression, context)
@staticmethod
def _expression_marker_source(
value: str, *, source: str | None = None
) -> str | None:
"""Return CEL source when the trimmed string starts with ``${`` and ends with ``}``."""
stripped = value.strip()
if not stripped.startswith("${"):
return None
if not stripped.endswith("}"):
return None
expression = stripped[2:-1].strip()
if not expression:
if source is None:
raise ExpressionError("empty CEL expression in with block")
raise ExpressionError(f"empty CEL expression at {source}")
return expression
@staticmethod
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
try:
from celpy import Environment
from celpy.adapter import CELJSONEncoder, json_to_cel
from celpy.evaluation import Context
environment = Environment()
program = environment.program(
Expression._compile_cel(expression, environment=environment)
)
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))
except Exception as e:
raise ExpressionError(
f"failed to evaluate CEL expression {expression!r}: {e}"
) from e
@staticmethod
def _compile_cel(
expression: str,
*,
source: str | None = None,
environment: Any | None = None,
) -> Any:
if environment is None:
from celpy import Environment
environment = Environment()
try:
return environment.compile(expression)
except Exception as e:
if source is None:
raise
raise ExpressionError(
f"invalid CEL expression at {source}: {expression!r}. "
f"Check the CEL syntax. Parser details: {e}"
) from e
@staticmethod
def _require_cel_source(value: str, *, source: str | None = None) -> str:
expression = value.strip()
if expression.startswith("${") and expression.endswith("}"):
expression = expression[2:-1].strip()
if expression:
return expression
if source is None:
raise ExpressionError("empty CEL expression")
raise ExpressionError(
f"empty CEL expression at {source}. Provide a CEL expression such as "
"state.topic or outputs.step_name."
)
@staticmethod
def _collect_root_identifiers(
tree: Any, local_roots: frozenset[str] = frozenset()
) -> set[str]:
"""Collect CEL root identifiers, excluding receiver macro local variables."""
data = getattr(tree, "data", None)
children = list(getattr(tree, "children", []) or [])
if data == "ident" and children:
name = str(children[0])
return set() if name in local_roots else {name}
if data == "ident_arg":
return Expression._collect_root_identifiers_from(
children[1:], local_roots=local_roots
)
if data == "member_dot_arg":
roots = (
Expression._collect_root_identifiers(children[0], local_roots)
if children
else set()
)
nested_locals = frozenset(
{*local_roots, *Expression._receiver_macro_local_roots(children)}
)
roots.update(
Expression._collect_root_identifiers_from(
children[2:], local_roots=nested_locals
)
)
return roots
return Expression._collect_root_identifiers_from(
children, local_roots=local_roots
)
@staticmethod
def _collect_root_identifiers_from(
trees: Iterable[Any], *, local_roots: frozenset[str]
) -> set[str]:
return set().union(
*(Expression._collect_root_identifiers(tree, local_roots) for tree in trees)
)
@staticmethod
def _receiver_macro_local_roots(children: list[Any]) -> set[str]:
if len(children) < 3 or str(children[1]) not in _CEL_MACROS_WITH_LOCAL_BINDINGS:
return set()
exprlist = children[2]
exprs = list(getattr(exprlist, "children", []) or [])
if exprs and (name := Expression._single_identifier_name(exprs[0])):
return {name}
return set()
@staticmethod
def _single_identifier_name(tree: Any) -> str | None:
data = getattr(tree, "data", None)
children = list(getattr(tree, "children", []) or [])
if data == "ident" and children:
return str(children[0])
if len(children) != 1:
return None
return Expression._single_identifier_name(children[0])

View File

@@ -12,13 +12,12 @@ from __future__ import annotations
import json
import logging
import re
from typing import Annotated, Any, Literal as TypingLiteral, TypeAlias
from typing import Annotated, Any, Literal, TypeAlias, cast
from pydantic import (
BaseModel,
ConfigDict,
Field,
RootModel,
field_serializer,
model_validator,
)
@@ -28,16 +27,21 @@ from crewai.flow.conversational_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
)
from crewai.project.crew_definition import CrewDefinition
from crewai.flow.expressions import ExpressionData
from crewai.project.crew_definition import AgentDefinition, CrewDefinition
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
_STEP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_BASE_CEL_ROOTS = frozenset({"outputs", "state"})
_EACH_STEP_CEL_ROOTS = frozenset({"item", "outputs", "state"})
__all__ = [
"FlowActionDefinition",
"FlowAgentActionDefinition",
"FlowAtomicActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
@@ -45,16 +49,16 @@ __all__ = [
"FlowCrewActionDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowDictStateDefinition",
"FlowEachActionDefinition",
"FlowEachInnerActionDefinition",
"FlowEachStepDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowJsonSchemaStateDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowPydanticStateDefinition",
"FlowScriptActionDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
"FlowUnknownStateDefinition",
@@ -69,21 +73,12 @@ def _object_ref(value: Any) -> str:
return f"{module}:{qualname}" if module and qualname else repr(value)
class FlowDefinitionDiagnostic(BaseModel):
"""A non-fatal Flow Definition build or validation diagnostic."""
code: str
message: str
severity: TypingLiteral["warning", "error"] = "warning"
path: str | None = None
class FlowDictStateDefinition(BaseModel):
"""Static description of a plain dictionary Flow state contract."""
model_config = ConfigDict(extra="forbid")
type: TypingLiteral["dict"] = Field(
type: Literal["dict"] = Field(
default="dict",
description="Plain dictionary state with optional default values.",
examples=["dict"],
@@ -100,7 +95,7 @@ class FlowPydanticStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: TypingLiteral["pydantic"] = Field(
type: Literal["pydantic"] = Field(
default="pydantic",
description="Importable Pydantic model used as the Flow state type.",
examples=["pydantic"],
@@ -135,7 +130,7 @@ class FlowJsonSchemaStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: TypingLiteral["json_schema"] = Field(
type: Literal["json_schema"] = Field(
default="json_schema",
description="Inline JSON Schema used as the Flow state contract.",
examples=["json_schema"],
@@ -162,7 +157,7 @@ class FlowUnknownStateDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
type: TypingLiteral["unknown"] = Field(
type: Literal["unknown"] = Field(
default="unknown",
description="Unknown state representation; runtime falls back to dictionary state.",
examples=["unknown"],
@@ -191,14 +186,46 @@ FlowStateDefinition: TypeAlias = Annotated[
class FlowConfigDefinition(BaseModel):
"""Serializable Flow-level configuration."""
tracing: bool | None = None
stream: bool = False
memory: dict[str, Any] | None = None
input_provider: str | None = None
suppress_flow_events: bool = False
max_method_calls: int = 100
defer_trace_finalization: bool = False
checkpoint: bool | dict[str, Any] | None = None
tracing: bool | None = Field(
default=None,
description="Override for flow tracing; when omitted, runtime defaults apply.",
examples=[True],
)
stream: bool = Field(
default=False,
description="Whether the flow should emit streaming events when supported.",
examples=[True],
)
memory: dict[str, Any] | None = Field(
default=None,
description="Serializable memory configuration passed to flow execution.",
examples=[{"enabled": True}],
)
input_provider: str | None = Field(
default=None,
description="Import reference or provider key used to supply flow inputs.",
examples=["my_project.inputs:load_inputs"],
)
suppress_flow_events: bool = Field(
default=False,
description="Disable flow event emission for this definition.",
examples=[False],
)
max_method_calls: int = Field(
default=100,
description="Maximum number of method executions allowed during one kickoff.",
examples=[20],
)
defer_trace_finalization: bool = Field(
default=False,
description="Defer trace finalization so callers can complete tracing later.",
examples=[False],
)
checkpoint: bool | dict[str, Any] | None = Field(
default=None,
description="Checkpointing configuration, or true to use default checkpointing.",
examples=[True, {"enabled": True}],
)
class FlowPersistenceDefinition(BaseModel):
@@ -210,9 +237,21 @@ class FlowPersistenceDefinition(BaseModel):
serialized config.
"""
enabled: bool = False
verbose: bool = False
persistence: Any = None
enabled: bool = Field(
default=False,
description="Whether persistence is enabled for this flow or method.",
examples=[True],
)
verbose: bool = Field(
default=False,
description="Whether persistence should emit verbose diagnostic output.",
examples=[False],
)
persistence: Any = Field(
default=None,
description="Persistence backend configuration or import reference.",
examples=[{"ref": "my_project.persistence:FlowStore"}],
)
@field_serializer("persistence", when_used="json")
def _serialize_persistence(self, value: Any) -> Any:
@@ -238,15 +277,53 @@ class FlowHumanFeedbackDefinition(BaseModel):
a serialized config (``llm``) or a ``module:qualname`` ref (``provider``).
"""
message: str
emit: list[str] | None = None
llm: Any = "gpt-4o-mini"
default_outcome: str | None = None
metadata: dict[str, Any] | None = None
provider: Any = None
learn: bool = False
learn_source: str = "hitl"
learn_strict: bool = False
message: str = Field(
description="Prompt shown to the human reviewer when feedback is requested.",
examples=["Review the research summary before publishing."],
)
emit: list[str] | None = Field(
default=None,
description=(
"Allowed feedback outcomes. When set, the method routes like a router "
"using the selected outcome."
),
examples=[["approved", "revise"]],
)
llm: Any = Field(
default="gpt-4o-mini",
description="LLM configuration used to assist or process human feedback.",
examples=["gpt-4o-mini"],
)
default_outcome: str | None = Field(
default=None,
description="Outcome to use when feedback cannot be collected.",
examples=["revise"],
)
metadata: dict[str, Any] | None = Field(
default=None,
description="Serializable metadata attached to the feedback request.",
examples=[{"team": "research"}],
)
provider: Any = Field(
default=None,
description="Feedback provider configuration or import reference.",
examples=["my_project.feedback:provider"],
)
learn: bool = Field(
default=False,
description="Whether feedback should be recorded for later learning workflows.",
examples=[True],
)
learn_source: str = Field(
default="hitl",
description="Source label attached to learned feedback records.",
examples=["hitl"],
)
learn_strict: bool = Field(
default=False,
description="Whether learning should enforce strict validation of feedback data.",
examples=[False],
)
@field_serializer("llm", when_used="json")
def _serialize_llm(self, value: Any) -> dict[str, Any] | str | None:
@@ -266,30 +343,124 @@ class FlowHumanFeedbackDefinition(BaseModel):
class FlowCodeActionDefinition(BaseModel):
"""A Flow method action that executes importable Python code."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: TypingLiteral["code"] = "code"
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
call: Literal["code"] = Field(
default="code",
description="Action discriminator. Use code to call importable Python.",
examples=["code"],
)
ref: str = Field(
description="Import reference for the callable, formatted as module:qualname.",
examples=["my_project.flows:normalize_topic"],
)
with_: dict[str, ExpressionData] | None = Field(
default=None,
alias="with",
description=(
"Keyword arguments passed to the callable. String values are evaluated "
"as CEL only when the trimmed value starts with ${ and ends with }; "
"all other values are literal."
),
examples=[{"topic": "${state.topic}"}],
)
class FlowToolActionDefinition(BaseModel):
"""A Flow method action that invokes a CrewAI tool."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: TypingLiteral["tool"]
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
call: Literal["tool"] = Field(
description="Action discriminator. Use tool to instantiate and run a CrewAI tool.",
examples=["tool"],
)
ref: str = Field(
description="Import reference for a BaseTool class, formatted as module:qualname.",
examples=["my_project.tools:SearchTool"],
)
with_: dict[str, ExpressionData] | None = Field(
default=None,
alias="with",
description=(
"Tool input arguments. String values are evaluated as CEL only when "
"the trimmed value starts with ${ and ends with }; all other values "
"are literal."
),
examples=[{"query": "${outputs.normalize_topic}", "limit": 5}],
)
class FlowCrewActionDefinition(BaseModel):
"""A Flow method action that builds and kicks off a CrewAI crew."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: TypingLiteral["crew"]
with_: CrewDefinition = Field(alias="with")
call: Literal["crew"] = Field(
description="Action discriminator. Use crew to run an inline Crew definition.",
examples=["crew"],
)
with_: CrewDefinition = Field(
alias="with",
description="Inline Crew definition to load and execute for this action.",
examples=[
{
"name": "inline_research",
"agents": {
"researcher": {
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows the domain.",
}
},
"tasks": [
{
"name": "research_task",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
}
],
)
class FlowAgentActionDefinition(BaseModel):
"""A Flow method action that builds and kicks off a CrewAI agent."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: Literal["agent"] = Field(
description="Action discriminator. Use agent to run an inline Agent definition.",
examples=["agent"],
)
with_: AgentDefinition = Field(
alias="with",
description="Inline Agent definition to load and execute for this action.",
examples=[
{
"role": "Analyst",
"goal": "Answer user questions",
"backstory": "Precise and concise.",
"settings": {"llm": "openai/gpt-4o-mini"},
"input": "${state.question}",
}
],
)
class FlowExpressionActionDefinition(BaseModel):
@@ -297,66 +468,143 @@ class FlowExpressionActionDefinition(BaseModel):
model_config = ConfigDict(extra="forbid")
call: TypingLiteral["expression"]
expr: str
call: Literal["expression"] = Field(
description="Action discriminator. Use expression to evaluate a CEL expression.",
examples=["expression"],
)
expr: str = Field(
description="CEL expression evaluated against state, outputs, and local context.",
examples=["state.topic", "outputs.normalize_topic"],
)
FlowInnerActionDefinition = (
class FlowScriptActionDefinition(BaseModel):
"""A Flow method action that executes trusted inline Python."""
model_config = ConfigDict(extra="forbid")
call: Literal["script"] = Field(
description="Action discriminator. Use script to execute trusted inline Python.",
examples=["script"],
)
code: str = Field(
description=(
"Trusted Python source executed as a generated function. Runtime values are "
"passed as state, outputs, input, and item; they are not interpolated into "
"the source. This is not sandboxed."
),
examples=[
"state['normalized_topic'] = input.strip()\n"
"return state['normalized_topic']"
],
)
language: Literal["python"] = Field(
default="python",
description="Script language. Only python is currently supported.",
examples=["python"],
)
FlowAtomicActionDefinition: TypeAlias = Annotated[
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowCrewActionDefinition
| FlowAgentActionDefinition
| FlowExpressionActionDefinition
)
| FlowScriptActionDefinition,
Field(discriminator="call"),
]
class FlowEachInnerActionDefinition(RootModel[dict[str, FlowInnerActionDefinition]]):
"""One named action inside an ``each`` composite action."""
class FlowEachStepDefinition(BaseModel):
"""One named step inside an ``each`` composite action."""
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
name: str = Field(
description="Step name used to reference this step's output.",
examples=["clean"],
)
if_: str | None = Field(
default=None,
alias="if",
description=(
"Optional CEL expression evaluated against state, outputs, and local "
"context. When present, the step runs only if the expression evaluates "
"to true."
),
examples=["item.kind == 'invoice'"],
)
action: FlowAtomicActionDefinition = Field(
description="Atomic action to run for this step.",
examples=[{"call": "script", "code": "return item.strip()"}],
)
@model_validator(mode="after")
def _validate_action_mapping(self) -> FlowEachInnerActionDefinition:
if len(self.root) != 1:
raise ValueError("each.do entries must be one-key mappings")
_validate_step_name(self.name, field="each.do action names")
def _validate_step_name(self) -> FlowEachStepDefinition:
_validate_step_name(self.name, field="each.do step names")
return self
@property
def name(self) -> str:
return next(iter(self.root))
@property
def action(self) -> FlowInnerActionDefinition:
return next(iter(self.root.values()))
class FlowEachActionDefinition(BaseModel):
"""A composite action that runs a sequential mini-pipeline for each item."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
model_config = ConfigDict(
populate_by_name=True,
extra="forbid",
)
call: TypingLiteral["each"]
in_: str = Field(alias="in")
do: list[FlowEachInnerActionDefinition]
call: Literal["each"] = Field(
description=(
"Action discriminator. Use each to run a sequence of actions for every "
"item in an input list."
),
examples=["each"],
)
in_: str = Field(
alias="in",
description="CEL expression that must evaluate to the list to iterate.",
examples=["state.rows"],
)
do: list[FlowEachStepDefinition] = Field(
description=(
"Ordered steps to run for each item. Each step has a name, optional "
"if expression, and atomic action."
),
examples=[
[
{
"name": "clean",
"action": {"call": "script", "code": "return item.strip()"},
},
{
"name": "tag",
"if": "outputs.clean != ''",
"action": {"call": "expression", "expr": "outputs.clean"},
},
]
],
)
@model_validator(mode="after")
def _validate_inner_action_list(self) -> FlowEachActionDefinition:
def _validate_step_list(self) -> FlowEachActionDefinition:
if not self.do:
raise ValueError("each.do must contain at least one action")
seen: set[str] = set()
for inner_action in self.do:
name = inner_action.name
if name in seen:
raise ValueError(f"each.do action names must be unique: {name!r}")
seen.add(name)
raise ValueError("each.do must contain at least one step")
_validate_step_list(self.do, field="each.do")
return self
FlowActionDefinition = (
FlowActionDefinition: TypeAlias = (
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowCrewActionDefinition
| FlowAgentActionDefinition
| FlowExpressionActionDefinition
| FlowScriptActionDefinition
| FlowEachActionDefinition
)
@@ -364,14 +612,48 @@ FlowActionDefinition = (
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
description: str | None = None
do: FlowActionDefinition
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
router: bool = False
emit: list[str] | None = None
human_feedback: FlowHumanFeedbackDefinition | None = None
persist: FlowPersistenceDefinition | None = None
description: str | None = Field(
default=None,
description="Human-readable summary of what this method does.",
examples=["Normalize the incoming topic."],
)
do: FlowActionDefinition = Field(
description="Action executed when this method runs.",
examples=[{"call": "script", "code": "return input.strip()"}],
)
start: bool | FlowDefinitionCondition | None = Field(
default=None,
description=(
"Marks a start method. True starts unconditionally; a condition starts "
"when the kickoff inputs or events satisfy it."
),
examples=[True],
)
listen: FlowDefinitionCondition | None = Field(
default=None,
description="Trigger condition that runs this method after upstream events.",
examples=["seed", {"or": ["approved", "revise"]}],
)
router: bool = Field(
default=False,
description="Whether the method output should be treated as the next event name.",
examples=[True],
)
emit: list[str] | None = Field(
default=None,
description="Declared router events this method may emit.",
examples=[["approved", "revise"]],
)
human_feedback: FlowHumanFeedbackDefinition | None = Field(
default=None,
description="Optional human feedback step applied after the method action.",
examples=[{"message": "Review the research summary before publishing."}],
)
persist: FlowPersistenceDefinition | None = Field(
default=None,
description="Method-level persistence override.",
examples=[{"enabled": True}],
)
@model_validator(mode="after")
def _canonicalize_human_feedback_routing(self) -> FlowMethodDefinition:
@@ -397,19 +679,57 @@ class FlowMethodDefinition(BaseModel):
class FlowDefinition(BaseModel):
"""Static, serializable definition of a Flow."""
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
schema_: TypingLiteral["crewai.flow/v1"] = Field(
default="crewai.flow/v1", alias="schema"
model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
)
schema_: Literal["crewai.flow/v1"] = Field(
default="crewai.flow/v1",
alias="schema",
description="Flow Definition schema identifier and version.",
examples=["crewai.flow/v1"],
)
name: str = Field(
description="Unique flow name used in logs, events, and traces.",
examples=["ResearchFlow"],
)
description: str | None = Field(
default=None,
description="Human-readable summary of the flow.",
examples=["Normalize a topic and prepare it for research."],
)
state: FlowStateDefinition | None = Field(
default=None,
description="State contract for kickoff inputs and runtime state.",
examples=[{"type": "dict", "default": {"topic": "AI agents"}}],
)
config: FlowConfigDefinition = Field(
default_factory=FlowConfigDefinition,
description="Serializable flow-level runtime configuration.",
examples=[{"stream": True, "max_method_calls": 20}],
)
persist: FlowPersistenceDefinition | None = Field(
default=None,
description="Flow-level persistence configuration.",
examples=[{"enabled": True}],
)
conversational: FlowConversationalDefinition | None = Field(
default=None,
description="Conversational flow configuration, when the flow supports chat.",
)
methods: dict[str, FlowMethodDefinition] = Field(
default_factory=dict,
description="Mapping of method names to method definitions.",
examples=[
{
"seed": {
"start": True,
"do": {"call": "expression", "expr": "state.topic"},
}
}
],
)
name: str
description: str | None = None
state: FlowStateDefinition | None = None
config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition)
persist: FlowPersistenceDefinition | None = None
conversational: FlowConversationalDefinition | None = None
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_method_names(self) -> FlowDefinition:
@@ -417,6 +737,16 @@ class FlowDefinition(BaseModel):
_validate_step_name(method_name, field="Flow method names")
return self
@model_validator(mode="after")
def _validate_cel_expressions(self) -> FlowDefinition:
for method_name, method in self.methods.items():
_validate_action_cel(
method.do,
path=f"methods.{method_name}.do",
allowed_roots=_BASE_CEL_ROOTS,
)
return self
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the definition to a JSON/YAML-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
@@ -436,13 +766,9 @@ class FlowDefinition(BaseModel):
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
"""Load a definition from a dictionary and attach diagnostics."""
serialized_diagnostics = _deserialize_diagnostics(data.get("diagnostics", []))
"""Load a definition from a dictionary."""
definition = cls.model_validate(data)
definition.diagnostics = _merge_diagnostics(
serialized_diagnostics, definition.validate_contract()
)
definition.log_diagnostics()
log_flow_definition_issues(definition)
return definition
@classmethod
@@ -463,122 +789,153 @@ class FlowDefinition(BaseModel):
"""Return the JSON Schema for the Flow Definition contract."""
return cls.model_json_schema(by_alias=True)
def validate_contract(self) -> list[FlowDefinitionDiagnostic]:
"""Validate the static contract without rejecting dynamic routing."""
diagnostics: list[FlowDefinitionDiagnostic] = []
for method_name, method in self.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
diagnostics.append(
FlowDefinitionDiagnostic(
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
)
if method.emit and not method.router:
diagnostics.append(
FlowDefinitionDiagnostic(
code="emit_without_router",
path=f"{path}.emit",
message="emit is only used by routers to declare downstream events",
)
)
if method.human_feedback:
human_feedback_config = method.human_feedback
if human_feedback_config.emit and not human_feedback_config.llm:
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_llm_required",
severity="error",
path=f"{path}.human_feedback.llm",
message="llm is required when human_feedback.emit is set",
)
)
if (
human_feedback_config.default_outcome is not None
and not human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_requires_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome requires human_feedback.emit",
)
)
elif (
human_feedback_config.default_outcome is not None
and human_feedback_config.emit
):
if (
human_feedback_config.default_outcome
not in human_feedback_config.emit
):
diagnostics.append(
FlowDefinitionDiagnostic(
code="human_feedback_default_not_in_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome must be one of human_feedback.emit",
)
)
return diagnostics
def with_diagnostics(self) -> FlowDefinition:
"""Attach fresh diagnostics and return this definition."""
self.diagnostics = self.validate_contract()
self.log_diagnostics()
return self
def log_diagnostics(self) -> None:
"""Emit all attached diagnostics through the flow definition logger."""
_log_flow_definition_diagnostics(self.name, self.diagnostics)
def _log_flow_definition_diagnostics(
definition_name: str,
diagnostics: list[FlowDefinitionDiagnostic],
) -> None:
for diagnostic in diagnostics:
level = logging.ERROR if diagnostic.severity == "error" else logging.WARNING
path = f" at {diagnostic.path}" if diagnostic.path else ""
logger.log(
level,
"Flow definition diagnostic for %s%s [%s]: %s",
definition_name,
path,
diagnostic.code,
diagnostic.message,
)
def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
def _validate_step_name(name: str, *, field: str) -> None:
if not isinstance(name, str) or not _STEP_NAME_PATTERN.fullmatch(name):
raise ValueError(f"{field} must match {_STEP_NAME_PATTERN.pattern}")
def _merge_diagnostics(
*diagnostic_groups: list[FlowDefinitionDiagnostic],
) -> list[FlowDefinitionDiagnostic]:
diagnostics: list[FlowDefinitionDiagnostic] = []
seen: set[tuple[str, str, str | None, str]] = set()
for group in diagnostic_groups:
for diagnostic in group:
key = (
diagnostic.code,
diagnostic.severity,
diagnostic.path,
diagnostic.message,
def _validate_step_list(steps: list[FlowEachStepDefinition], *, field: str) -> None:
seen: set[str] = set()
for step in steps:
name = step.name
if name in seen:
raise ValueError(f"{field} step names must be unique: {name!r}")
seen.add(name)
def _validate_action_cel(
action: FlowActionDefinition,
*,
path: str,
allowed_roots: frozenset[str],
) -> None:
from crewai.flow.expressions import Expression
if isinstance(action, FlowExpressionActionDefinition):
Expression(action.expr).validate_expression(
allowed_roots=allowed_roots, source=f"{path}.expr"
)
return
if isinstance(action, (FlowCodeActionDefinition, FlowToolActionDefinition)):
if action.with_ is not None:
Expression(action.with_).validate_template(
allowed_roots=allowed_roots, source=f"{path}.with"
)
if key in seen:
continue
seen.add(key)
diagnostics.append(diagnostic)
return diagnostics
return
if isinstance(action, FlowCrewActionDefinition):
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
return
if isinstance(action, FlowAgentActionDefinition):
Expression(cast(ExpressionData, action.with_.input)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.input",
)
return
if isinstance(action, FlowEachActionDefinition):
Expression(action.in_).validate_expression(
allowed_roots=_BASE_CEL_ROOTS,
source=f"{path}.in",
)
for index, step in enumerate(action.do):
step_path = f"{path}.do[{index}]"
if step.if_ is not None:
Expression(step.if_).validate_expression(
allowed_roots=_EACH_STEP_CEL_ROOTS,
source=f"{step_path}.if",
)
_validate_action_cel(
step.action,
path=f"{step_path}.action",
allowed_roots=_EACH_STEP_CEL_ROOTS,
)
return
if isinstance(action, FlowScriptActionDefinition):
return
raise TypeError(
f"no CEL validation defined for action type {type(action).__name__} at "
f"{path}; add a branch to _validate_action_cel for it."
)
def log_flow_definition_issues(definition: FlowDefinition) -> None:
for method_name, method in definition.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
_log_flow_definition_issue(
definition.name,
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
if method.emit and not method.router:
_log_flow_definition_issue(
definition.name,
code="emit_without_router",
path=f"{path}.emit",
message="emit is only used by routers to declare downstream events",
)
if method.human_feedback:
human_feedback_config = method.human_feedback
if human_feedback_config.emit and not human_feedback_config.llm:
_log_flow_definition_issue(
definition.name,
code="human_feedback_llm_required",
severity="error",
path=f"{path}.human_feedback.llm",
message="llm is required when human_feedback.emit is set",
)
if (
human_feedback_config.default_outcome is not None
and not human_feedback_config.emit
):
_log_flow_definition_issue(
definition.name,
code="human_feedback_default_requires_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome requires human_feedback.emit",
)
elif (
human_feedback_config.default_outcome is not None
and human_feedback_config.emit
and human_feedback_config.default_outcome
not in human_feedback_config.emit
):
_log_flow_definition_issue(
definition.name,
code="human_feedback_default_not_in_emit",
severity="error",
path=f"{path}.human_feedback.default_outcome",
message="default_outcome must be one of human_feedback.emit",
)
def _log_flow_definition_issue(
definition_name: str,
*,
code: str,
message: str,
severity: Literal["warning", "error"] = "warning",
path: str | None = None,
) -> None:
level = logging.ERROR if severity == "error" else logging.WARNING
location = f" at {path}" if path else ""
logger.log(
level,
"Flow definition issue for %s%s [%s]: %s",
definition_name,
location,
code,
message,
)

View File

@@ -121,7 +121,7 @@ from crewai.flow.human_feedback import (
)
from crewai.flow.input_provider import InputProvider
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._actions import build_action
from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError, build_action
from crewai.flow.runtime._refs import resolve_instance_ref, resolve_ref
from crewai.flow.types import (
FlowExecutionData,
@@ -1090,6 +1090,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
def build(name: str, definition: FlowMethodDefinition) -> Callable[..., Any]:
try:
return build_action(self, definition.do)
except FlowScriptExecutionDisabledError:
raise
except Exception as e:
unresolved.append(f"{name}: {e}")
return lambda *args, **kwargs: None

View File

@@ -2,22 +2,27 @@
from __future__ import annotations
import ast
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
import contextvars
import inspect
import os
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.expressions import Expression, ExpressionData
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowAgentActionDefinition,
FlowCodeActionDefinition,
FlowCrewActionDefinition,
FlowEachActionDefinition,
FlowEachInnerActionDefinition,
FlowEachStepDefinition,
FlowExpressionActionDefinition,
FlowScriptActionDefinition,
FlowToolActionDefinition,
)
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
from crewai.flow.runtime._outputs import outputs_by_name
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
@@ -25,10 +30,18 @@ if TYPE_CHECKING:
from crewai.flow.runtime import Flow
__all__ = ["build_action"]
__all__ = ["FlowScriptExecutionDisabledError", "build_action"]
LocalContext = dict[str, Any]
NestedStepRunner = Callable[[LocalContext], Awaitable[Any]]
NestedStep = tuple[str, str | None, NestedStepRunner]
_LOCAL_CONTEXT_KWARG = "__flow_definition_local_context"
_ALLOW_SCRIPT_EXECUTION_ENV_VAR = "CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION"
_TRUSTED_SCRIPT_EXECUTION_VALUES = frozenset({"1", "true", "yes"})
class FlowScriptExecutionDisabledError(RuntimeError):
"""Raised when a flow definition tries to execute inline script code."""
class _BuiltAction(Protocol):
@@ -55,9 +68,9 @@ class CodeAction:
if self.definition.with_ is None:
return self.handler(*args, **kwargs)
return self.handler(
**render_with_block(
self.flow, self.definition.with_, local_context=local_context
)
**Expression.from_flow(
self.definition.with_, self.flow, local_context=local_context
).render_template()
)
def _resolve_handler(self) -> Callable[..., Any]:
@@ -83,7 +96,9 @@ class ToolAction:
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return self.tool.run(
**render_with_block(self.flow, self.kwargs, local_context=local_context)
**Expression.from_flow(
self.kwargs, self.flow, local_context=local_context
).render_template()
)
def _build_tool(self) -> Any:
@@ -117,13 +132,44 @@ class CrewAction:
local_context = _pop_local_context(kwargs)
crew_definition = self.definition.with_
inputs = render_with_block(
self.flow, crew_definition.inputs, local_context=local_context
)
inputs = Expression.from_flow(
cast(ExpressionData, crew_definition.inputs),
self.flow,
local_context=local_context,
).render_template()
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
return await crew.kickoff_async(inputs=inputs)
class AgentAction:
definition_type = FlowAgentActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowAgentActionDefinition) -> None:
self.flow = flow
self.definition = definition
async def run(self, *_args: Any, **kwargs: Any) -> Any:
from crewai.project.json_loader import load_agent_from_definition
local_context = _pop_local_context(kwargs)
rendered_input = Expression.from_flow(
cast(ExpressionData, self.definition.with_.input),
self.flow,
local_context=local_context,
).render_template()
if not isinstance(rendered_input, str):
raise ValueError("agent input must render to a string")
agent, response_format = load_agent_from_definition(
self.definition.with_,
source="agent action",
)
return await agent.kickoff_async(
rendered_input,
response_format=response_format,
)
class ExpressionAction:
definition_type = FlowExpressionActionDefinition
@@ -135,10 +181,71 @@ class ExpressionAction:
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return evaluate_expression(
self.flow, self.definition.expr, local_context=local_context
return Expression.from_flow(
self.definition.expr, self.flow, local_context=local_context
).evaluate()
class ScriptAction:
definition_type = FlowScriptActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowScriptActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.handler = self._compile_handler()
def run(self, *args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return self.handler(
state=self.flow.state,
outputs=outputs_by_name(
self.flow._method_outputs,
local_outputs=local_context.get("outputs") if local_context else None,
),
input=args[0] if args else None,
item=local_context.get("item") if local_context else None,
)
def _compile_handler(self) -> Callable[..., Any]:
raw = os.environ.get(_ALLOW_SCRIPT_EXECUTION_ENV_VAR, "")
if raw.strip().lower() not in _TRUSTED_SCRIPT_EXECUTION_VALUES:
raise FlowScriptExecutionDisabledError(
"Flow script execution is disabled by default. "
f"Set {_ALLOW_SCRIPT_EXECUTION_ENV_VAR}=1 to enable it only for "
"trusted flow definitions."
)
filename = f"crewai.flow.script.{self.flow._definition.name}"
module = ast.parse(self.definition.code, filename=filename)
function = ast.FunctionDef(
name="_flow_script",
args=ast.arguments(
posonlyargs=[],
args=[ast.arg(arg) for arg in ("state", "outputs", "input", "item")],
vararg=None,
kwonlyargs=[],
kw_defaults=[],
kwarg=None,
defaults=[],
),
body=module.body or [ast.Pass()],
decorator_list=[],
returns=None,
type_comment=None,
type_params=[],
)
module.body = [function]
ast.fix_missing_locations(module)
# The YAML here is trusted project source authored by the code owner,
# so this has the same trust boundary as using custom tools. We
# intentionally do not interpolate user input and runtime values are passed
# as function arguments. This is still arbitrary trusted Python execution,
# so it remains disabled by default behind `CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION`
namespace: dict[str, Any] = {"__name__": filename}
exec(compile(module, filename, "exec"), namespace) # nosec B102 # noqa: S102
return cast(Callable[..., Any], namespace["_flow_script"])
class EachAction:
definition_type = FlowEachActionDefinition
@@ -146,13 +253,13 @@ class EachAction:
def __init__(self, flow: Flow[Any], definition: FlowEachActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.inner_actions = [
(inner_action.name, self._build_inner_action(inner_action))
for inner_action in definition.do
self.steps: list[NestedStep] = [
(step.name, step.if_, self._build_step_action(step))
for step in definition.do
]
async def run(self, *_args: Any, **_kwargs: Any) -> list[Any]:
items = evaluate_expression(self.flow, self.definition.in_)
items = Expression.from_flow(self.definition.in_, self.flow).evaluate()
if not isinstance(items, list):
raise ValueError("each.in must evaluate to an array")
@@ -160,22 +267,32 @@ class EachAction:
for item in items:
local_outputs: dict[str, Any] = {}
local_context = {"item": item, "outputs": local_outputs}
last_output: Any = None
for name, run_inner_action in self.inner_actions:
last_output = await run_inner_action(
{"item": item, "outputs": local_outputs}
)
for name, condition, run_step_action in self.steps:
if condition is not None and not self._condition_matches(
condition, local_context
):
continue
last_output = await run_step_action(local_context)
local_outputs[name] = last_output
results.append(last_output)
return results
def _build_inner_action(
self, inner_action: FlowEachInnerActionDefinition
) -> Callable[[LocalContext], Any]:
run_action = build_action(self.flow, inner_action.action)
def _condition_matches(self, condition: str, local_context: LocalContext) -> bool:
result = Expression.from_flow(
condition, self.flow, local_context=local_context
).evaluate()
if not isinstance(result, bool):
raise ValueError("if expression must evaluate to a boolean")
return result
async def run_inner_action(local_context: LocalContext) -> Any:
def _build_step_action(self, step: FlowEachStepDefinition) -> NestedStepRunner:
run_action = build_action(self.flow, step.action)
async def run_step_action(local_context: LocalContext) -> Any:
kwargs = {_LOCAL_CONTEXT_KWARG: local_context}
if inspect.iscoroutinefunction(run_action):
result = run_action(**kwargs)
@@ -190,15 +307,17 @@ class EachAction:
result = await result
return result
return run_inner_action
return run_step_action
_ACTION_TYPES: tuple[_ActionType, ...] = (
EachAction,
CodeAction,
ToolAction,
AgentAction,
CrewAction,
ExpressionAction,
ScriptAction,
)

View File

@@ -1,157 +0,0 @@
"""Runtime expression support for FlowDefinition CEL expressions."""
from __future__ import annotations
from itertools import pairwise
import json
import re
from typing import TYPE_CHECKING, Any, cast
from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
_EXPRESSION_PATTERN = re.compile(r"\$\{([^{}]*)\}")
__all__ = ["FlowExpressionError", "evaluate_expression", "render_with_block"]
class FlowExpressionError(ValueError):
"""A FlowDefinition expression failed to parse or evaluate."""
def render_with_block(
flow: Flow[Any], value: Any, local_context: dict[str, Any] | None = None
) -> Any:
"""Render CEL expressions inside a FlowDefinition ``with:`` payload."""
context = _expression_context(flow, local_context=local_context)
return _render_value(value, context)
def evaluate_expression(
flow: Flow[Any], expression: str, local_context: dict[str, Any] | None = None
) -> Any:
"""Evaluate a FlowDefinition CEL expression against runtime context."""
expression = expression.strip()
if not expression:
raise FlowExpressionError("empty CEL expression")
return _eval_cel(expression, _expression_context(flow, local_context=local_context))
def _expression_context(
flow: Flow[Any], local_context: dict[str, Any] | None = None
) -> dict[str, Any]:
outputs = _outputs_by_name(flow._method_outputs)
context: dict[str, Any] = {
"state": flow._copy_and_serialize_state(),
"outputs": outputs,
}
if local_context:
local_values = {
key: to_serializable(value, max_depth=0)
for key, value in local_context.items()
}
local_outputs = local_values.pop("outputs", None)
local_values.pop("state", None)
context.update(local_values)
if local_outputs is not None:
if not isinstance(local_outputs, dict):
raise TypeError("flow definition local outputs must be a mapping")
context["outputs"] = {**outputs, **local_outputs}
return context
def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
outputs: dict[str, Any] = {}
for entry in method_outputs:
method = ""
output = entry
if isinstance(entry, dict) and "output" in entry:
method = str(entry.get("method", ""))
output = entry["output"]
outputs[method] = to_serializable(output, max_depth=0)
return outputs
def _render_value(value: Any, context: dict[str, Any]) -> Any:
if isinstance(value, str):
return _render_string(value, context)
if isinstance(value, dict):
return {key: _render_value(item, context) for key, item in value.items()}
if isinstance(value, list):
return [_render_value(item, context) for item in value]
return value
def _render_string(value: str, context: dict[str, Any]) -> Any:
matches = list(_EXPRESSION_PATTERN.finditer(value))
if not matches:
_raise_for_invalid_interpolation(value)
return value
_raise_for_literal_braces(value[: matches[0].start()])
for previous, current in pairwise(matches):
_raise_for_literal_braces(value[previous.end() : current.start()])
_raise_for_literal_braces(value[matches[-1].end() :])
if len(matches) == 1 and matches[0].span() == (0, len(value)):
expression = matches[0].group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
return _eval_cel(expression, context)
rendered: list[str] = []
position = 0
for match in matches:
start, end = match.span()
literal = value[position:start]
rendered.append(literal)
expression = match.group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
result = _eval_cel(expression, context)
rendered.append(result if isinstance(result, str) else json.dumps(result))
position = end
literal = value[position:]
rendered.append(literal)
return "".join(rendered)
def _raise_for_invalid_interpolation(value: str) -> None:
if "${" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _raise_for_literal_braces(value: str) -> None:
if "{" not in value and "}" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _eval_cel(expression: str, context: dict[str, Any]) -> Any:
try:
from celpy import Environment
from celpy.adapter import CELJSONEncoder, json_to_cel
from celpy.evaluation import Context
environment = Environment()
program = environment.program(environment.compile(expression))
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))
except Exception as e:
raise FlowExpressionError(
f"failed to evaluate CEL expression {expression!r}: {e}"
) from e

View File

@@ -0,0 +1,40 @@
"""Shared FlowDefinition runtime output helpers."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, TypedDict
from crewai.utilities.serialization import to_serializable
class _MethodOutput(TypedDict):
method: str
output: Any
def outputs_by_name(
method_outputs: list[_MethodOutput],
*,
local_outputs: Mapping[str, Any] | None = None,
serialize: bool = False,
) -> dict[str, Any]:
outputs: dict[str, Any] = {}
for entry in method_outputs:
outputs[entry["method"]] = _output_value(entry["output"], serialize=serialize)
if local_outputs is not None:
outputs.update(
{
key: _output_value(output, serialize=serialize)
for key, output in local_outputs.items()
}
)
return outputs
def _output_value(value: Any, *, serialize: bool) -> Any:
if not serialize:
return value
return to_serializable(value, max_depth=0)

View File

@@ -40,6 +40,8 @@ class ToolCallHookContext:
crew: Crew instance (may be None)
tool_result: Tool execution result (only set for after_tool_call hooks).
Can be modified by returning a new string from after_tool_call hook.
raw_tool_result: Raw Python tool execution result (only set for
after_tool_call hooks). This is not modified by after hooks.
"""
def __init__(
@@ -51,6 +53,7 @@ class ToolCallHookContext:
task: Task | None = None,
crew: Crew | None = None,
tool_result: str | None = None,
raw_tool_result: Any | None = None,
) -> None:
"""Initialize tool call hook context.
@@ -62,6 +65,7 @@ class ToolCallHookContext:
task: Optional current task
crew: Optional crew instance
tool_result: Optional tool result (for after hooks)
raw_tool_result: Optional raw tool result (for after hooks)
"""
self.tool_name = tool_name
self.tool_input = tool_input
@@ -70,6 +74,7 @@ class ToolCallHookContext:
self.task = task
self.crew = crew
self.tool_result = tool_result
self.raw_tool_result = raw_tool_result
def request_human_input(
self,

View File

@@ -3,7 +3,9 @@
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import suppress
import contextvars
import copy
from datetime import datetime
import threading
import time
@@ -53,6 +55,24 @@ def _default_embedder() -> OpenAIEmbeddingFunction:
return build_embedder(spec)
def _non_streaming_analysis_llm(llm: Any) -> Any:
"""Return an isolated non-streaming LLM for internal memory analysis."""
if not isinstance(llm, BaseLLM):
return llm
try:
analysis_llm = copy.copy(llm)
except Exception:
try:
analysis_llm = llm.model_copy(deep=False)
except Exception:
return llm
with suppress(Exception):
analysis_llm.stream = False
return analysis_llm
class Memory(BaseModel):
"""Unified memory: standalone, LLM-analyzed, with intelligent recall flow.
@@ -200,7 +220,9 @@ class Memory(BaseModel):
query_analysis_threshold=self.query_analysis_threshold,
)
self._llm_instance = None if isinstance(self.llm, str) else self.llm
self._llm_instance = (
None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm)
)
self._embedder_instance = (
self.embedder
if (self.embedder is not None and not isinstance(self.embedder, dict))

View File

@@ -15,16 +15,22 @@ from crewai.project.annotations import (
)
from crewai.project.crew_base import CrewBase
from crewai.project.crew_definition import (
AgentDefinition,
CrewAgentDefinition,
CrewDefinition,
CrewTaskDefinition,
PythonReferenceDefinition,
)
from crewai.project.crew_loader import load_crew, load_crew_and_kickoff
from crewai.project.json_loader import load_agent, strip_jsonc_comments
from crewai.project.json_loader import (
load_agent,
load_agent_from_definition,
strip_jsonc_comments,
)
__all__ = [
"AgentDefinition",
"CrewAgentDefinition",
"CrewBase",
"CrewDefinition",
@@ -38,6 +44,7 @@ __all__ = [
"crew",
"llm",
"load_agent",
"load_agent_from_definition",
"load_crew",
"load_crew_and_kickoff",
"output_json",

View File

@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
__all__ = [
"AgentDefinition",
"CrewAgentDefinition",
"CrewDefinition",
"CrewTaskDefinition",
@@ -53,6 +54,20 @@ class CrewAgentDefinition(BaseModel):
return value or {}
class AgentDefinition(CrewAgentDefinition):
"""Inline agent definition used by a Flow agent action."""
input: str
response_format: PythonReferenceDefinition | None = None
@field_validator("input", mode="before")
@classmethod
def _validate_input(cls, value: Any) -> Any:
if not isinstance(value, str):
raise ValueError("agent.input must be a string")
return value
class CrewTaskDefinition(BaseModel):
"""Task definition used by a crew definition."""

View File

@@ -207,19 +207,18 @@ def load_jsonc_file(source: str | Path) -> Any:
return parse_jsonc(path.read_text(encoding="utf-8"), source=path)
def load_agent(source: str | Path) -> Any:
"""Load an existing ``Agent`` from a ``.json`` / ``.jsonc`` definition file."""
path = Path(source)
defn = _expect_object(load_jsonc_file(path), path)
root = path.parent.parent if path.parent.name == "agents" else path.parent
def _instantiate_agent_from_data(
defn: dict[str, Any], source_label: str, root: Path
) -> Any:
"""Resolve the agent class and kwargs from definition data and instantiate it."""
agent_class = _agent_class_from_definition(
defn,
f"{path}: type",
f"{source_label}: type",
project_root=root,
)
agent_kwargs = _agent_kwargs_from_definition(
defn,
path,
source_label,
agent_class=agent_class,
project_root=root,
)
@@ -227,9 +226,50 @@ def load_agent(source: str | Path) -> Any:
try:
return agent_class(**agent_kwargs)
except ValidationError as exc:
raise JSONProjectError(_format_validation_error(path, exc)) from exc
raise JSONProjectError(_format_validation_error(source_label, exc)) from exc
except Exception as exc:
raise JSONProjectError(f"{path}: failed to load agent: {exc}") from exc
raise JSONProjectError(f"{source_label}: failed to load agent: {exc}") from exc
def load_agent(source: str | Path) -> Any:
"""Load an existing ``Agent`` from a ``.json`` / ``.jsonc`` definition file."""
path = Path(source)
defn = _expect_object(load_jsonc_file(path), path)
root = path.parent.parent if path.parent.name == "agents" else path.parent
return _instantiate_agent_from_data(defn, str(path), root)
def load_agent_from_definition(
definition: dict[str, Any] | Any,
*,
source: str | Path = "<inline agent>",
project_root: str | Path | None = None,
) -> tuple[Any, type[BaseModel] | None]:
"""Load an ``Agent`` and optional kickoff response model from an inline definition."""
from crewai.project.crew_definition import AgentDefinition
root = Path(project_root) if project_root is not None else Path.cwd()
source_label = str(source)
agent_definition = (
definition
if isinstance(definition, AgentDefinition)
else AgentDefinition.model_validate(definition)
)
definition_data = agent_definition.model_dump(mode="python", exclude_none=True)
response_format_ref = definition_data.pop("response_format", None)
definition_data.pop("input", None)
agent = _instantiate_agent_from_data(definition_data, source_label, root)
response_format = None
if response_format_ref is not None:
response_format = _resolve_model_class(
response_format_ref,
f"{source_label}: response_format",
root,
)
return agent, response_format
def validate_crew_project(

View File

@@ -33,6 +33,8 @@ from typing_extensions import TypeIs
from crewai.tools.structured_tool import (
CrewStructuredTool,
_deserialize_schema,
_format_tool_output_for_agent,
_infer_result_schema_from_callable,
_serialize_schema,
build_schema_hint,
)
@@ -149,6 +151,11 @@ class BaseTool(BaseModel, ABC):
validate_default=True,
description="The schema for the arguments that the tool accepts.",
)
result_schema: type[PydanticBaseModel] | None = Field(
default=None,
validate_default=True,
description="The schema for the output that the tool returns.",
)
@field_serializer("args_schema", when_used="json")
def _serialize_args_schema(
@@ -156,6 +163,12 @@ class BaseTool(BaseModel, ABC):
) -> dict[str, Any] | None:
return _serialize_schema(schema)
@field_serializer("result_schema", when_used="json")
def _serialize_result_schema(
self, schema: type[PydanticBaseModel] | None
) -> dict[str, Any] | None:
return _serialize_schema(schema)
description_updated: bool = Field(
default=False, description="Flag to check if the description has been updated."
)
@@ -233,6 +246,17 @@ class BaseTool(BaseModel, ABC):
return create_model(f"{cls.__name__}Schema", **fields)
@field_validator("result_schema", mode="before")
@classmethod
def _default_result_schema(
cls, v: type[PydanticBaseModel] | dict[str, Any] | None
) -> type[PydanticBaseModel] | None:
if isinstance(v, dict):
return _deserialize_schema(v)
if v is not None:
return v
return _infer_result_schema_from_callable(cls._run)
@field_validator("max_usage_count", mode="before")
@classmethod
def validate_max_usage_count(cls, v: int | None) -> int | None:
@@ -340,6 +364,10 @@ class BaseTool(BaseModel, ABC):
"Override _arun for async support or use run() for sync execution."
)
def format_output_for_agent(self, raw_result: Any) -> str:
"""Format a raw tool result into the string representation sent to an agent."""
return _format_tool_output_for_agent(self, raw_result)
def reset_usage_count(self) -> None:
"""Reset the current usage count to zero."""
self.current_usage_count = 0
@@ -369,6 +397,7 @@ class BaseTool(BaseModel, ABC):
name=self.name,
description=self.description,
args_schema=self.args_schema,
result_schema=self.result_schema,
func=self._run,
result_as_answer=self.result_as_answer,
max_usage_count=self.max_usage_count,
@@ -390,6 +419,9 @@ class BaseTool(BaseModel, ABC):
raise ValueError("The provided tool must have a callable 'func' attribute.")
args_schema = getattr(tool, "args_schema", None)
result_schema = getattr(tool, "result_schema", None)
if result_schema is None:
result_schema = _infer_result_schema_from_callable(tool.func)
if args_schema is None:
func_signature = signature(tool.func)
@@ -420,6 +452,7 @@ class BaseTool(BaseModel, ABC):
description=getattr(tool, "description", ""),
func=tool.func,
args_schema=args_schema,
result_schema=result_schema,
)
def _set_args_schema(self) -> None:
@@ -568,6 +601,9 @@ class Tool(BaseTool, Generic[P, R]):
raise ValueError("The provided tool must have a callable 'func' attribute.")
args_schema = getattr(tool, "args_schema", None)
result_schema = getattr(tool, "result_schema", None)
if result_schema is None:
result_schema = _infer_result_schema_from_callable(tool.func)
if args_schema is None:
func_signature = signature(tool.func)
@@ -598,6 +634,7 @@ class Tool(BaseTool, Generic[P, R]):
description=getattr(tool, "description", ""),
func=tool.func,
args_schema=args_schema,
result_schema=result_schema,
)
@@ -621,6 +658,7 @@ def tool(
name: str,
/,
*,
result_schema: type[BaseModel] | None = ...,
result_as_answer: bool = ...,
max_usage_count: int | None = ...,
) -> Callable[[Callable[P2, R2]], Tool[P2, R2]]: ...
@@ -629,6 +667,7 @@ def tool(
@overload
def tool(
*,
result_schema: type[BaseModel] | None = ...,
result_as_answer: bool = ...,
max_usage_count: int | None = ...,
) -> Callable[[Callable[P2, R2]], Tool[P2, R2]]: ...
@@ -636,6 +675,7 @@ def tool(
def tool(
*args: Callable[P2, R2] | str,
result_schema: type[BaseModel] | None = None,
result_as_answer: bool = False,
max_usage_count: int | None = None,
) -> Tool[P2, R2] | Callable[[Callable[P2, R2]], Tool[P2, R2]]:
@@ -649,6 +689,7 @@ def tool(
Args:
*args: Either the function to decorate or a custom tool name.
result_as_answer: If True, the tool result becomes the final agent answer.
result_schema: Optional schema for the output that the tool returns.
max_usage_count: Maximum times this tool can be used. None means unlimited.
Returns:
@@ -690,12 +731,16 @@ def tool(
class_name = "".join(tool_name.split()).title()
args_schema = create_model(class_name, **fields)
resolved_result_schema = (
result_schema or _infer_result_schema_from_callable(f)
)
return Tool(
name=tool_name,
description=f.__doc__,
func=f,
args_schema=args_schema,
result_schema=resolved_result_schema,
result_as_answer=result_as_answer,
max_usage_count=max_usage_count,
current_usage_count=0,

View File

@@ -5,7 +5,8 @@ from collections.abc import Callable
import inspect
import json
import textwrap
from typing import TYPE_CHECKING, Annotated, Any, get_type_hints
from typing import TYPE_CHECKING, Annotated, Any, cast, get_type_hints
import warnings
from pydantic import (
BaseModel,
@@ -36,6 +37,52 @@ def _deserialize_schema(v: Any) -> type[BaseModel] | None:
return None
def _infer_result_schema_from_callable(
func: Callable[..., Any],
) -> type[BaseModel] | None:
try:
return_annotation = get_type_hints(func).get("return", inspect.Signature.empty)
except Exception:
return_annotation = inspect.signature(func).return_annotation
if isinstance(return_annotation, type) and issubclass(return_annotation, BaseModel):
return return_annotation
return None
def _format_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
original_tool = getattr(tool, "_original_tool", None)
if original_tool is not None:
return cast(str, original_tool.format_output_for_agent(raw_result))
result_schema = getattr(tool, "result_schema", None)
if not (isinstance(result_schema, type) and issubclass(result_schema, BaseModel)):
return str(raw_result)
try:
validation_input = raw_result
if isinstance(raw_result, BaseModel) and not isinstance(
raw_result, result_schema
):
validation_input = raw_result.model_dump()
validated = result_schema.model_validate(validation_input)
return validated.model_dump_json()
except Exception as exc:
warnings.warn(
(
f"Failed to validate or serialize output from tool "
f"'{getattr(tool, 'name', '<unknown>')}' using result_schema "
f"'{result_schema.__name__}': {exc.__class__.__name__}. "
"Falling back to str(raw_result)."
),
RuntimeWarning,
stacklevel=2,
)
return str(raw_result)
if TYPE_CHECKING:
pass
@@ -81,6 +128,11 @@ class CrewStructuredTool(BaseModel):
BeforeValidator(_deserialize_schema),
PlainSerializer(_serialize_schema),
] = Field(default=None)
result_schema: Annotated[
type[BaseModel] | None,
BeforeValidator(_deserialize_schema),
PlainSerializer(_serialize_schema),
] = Field(default=None)
func: Any = Field(default=None, exclude=True)
result_as_answer: bool = Field(default=False)
max_usage_count: int | None = Field(default=None)
@@ -103,6 +155,7 @@ class CrewStructuredTool(BaseModel):
description: str | None = None,
return_direct: bool = False,
args_schema: type[BaseModel] | None = None,
result_schema: type[BaseModel] | None = None,
infer_schema: bool = True,
**kwargs: Any,
) -> CrewStructuredTool:
@@ -114,6 +167,7 @@ class CrewStructuredTool(BaseModel):
description: The description of the tool. Defaults to the function docstring
return_direct: Whether to return the output directly
args_schema: Optional schema for the function arguments
result_schema: Optional schema for the function output
infer_schema: Whether to infer the schema from the function signature
**kwargs: Additional arguments to pass to the tool
@@ -149,10 +203,16 @@ class CrewStructuredTool(BaseModel):
name=name,
description=description,
args_schema=schema,
result_schema=result_schema or _infer_result_schema_from_callable(func),
func=func,
result_as_answer=return_direct,
**kwargs,
)
def format_output_for_agent(self, raw_result: Any) -> str:
"""Format a raw tool result into the string representation sent to an agent."""
return _format_tool_output_for_agent(self, raw_result)
@staticmethod
def _create_schema_from_function(
name: str,

View File

@@ -62,6 +62,9 @@ OPENAI_BIGGER_MODELS: list[
]
_RAW_RESULT_UNSET = object()
class ToolUsageError(Exception):
"""Exception raised for errors in the tool usage."""
@@ -106,6 +109,7 @@ class ToolUsage:
self.action = action
self.function_calling_llm = function_calling_llm
self.fingerprint_context = fingerprint_context or {}
self.last_raw_result: Any = _RAW_RESULT_UNSET
if (
self.function_calling_llm
@@ -120,6 +124,11 @@ class ToolUsage:
"""Parse the tool string and return the tool calling."""
return self._tool_calling(tool_string)
def get_last_raw_result(self, fallback: Any) -> Any:
if self.last_raw_result is _RAW_RESULT_UNSET:
return fallback
return self.last_raw_result
def use(
self, calling: ToolCalling | InstructorToolCalling, tool_string: str
) -> str:
@@ -231,6 +240,7 @@ class ToolUsage:
result = I18N_DEFAULT.errors("task_repeated_usage").format(
tool_names=self.tools_names
)
self.last_raw_result = result
self._telemetry.tool_repeated_usage(
llm=self.function_calling_llm,
tool_name=sanitize_tool_name(tool.name),
@@ -298,6 +308,7 @@ class ToolUsage:
)
if usage_limit_error:
result = usage_limit_error
self.last_raw_result = result
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -359,7 +370,10 @@ class ToolUsage:
tool_name=sanitize_tool_name(tool.name),
attempts=self._run_attempts,
)
result = self._format_result(result=result)
self.last_raw_result = result
result = self._format_result(
result=tool.format_output_for_agent(result)
)
data = {
"result": result,
"tool_name": sanitize_tool_name(tool.name),
@@ -421,6 +435,7 @@ class ToolUsage:
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
self.last_raw_result = result
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -430,7 +445,10 @@ class ToolUsage:
self.task.increment_tools_errors()
should_retry = True
else:
result = self._format_result(result=result)
self.last_raw_result = result
result = self._format_result(
result=tool.format_output_for_agent(result)
)
finally:
if started_event_emitted and not error_event_emitted:
@@ -460,6 +478,7 @@ class ToolUsage:
result = I18N_DEFAULT.errors("task_repeated_usage").format(
tool_names=self.tools_names
)
self.last_raw_result = result
self._telemetry.tool_repeated_usage(
llm=self.function_calling_llm,
tool_name=sanitize_tool_name(tool.name),
@@ -529,6 +548,7 @@ class ToolUsage:
)
if usage_limit_error:
result = usage_limit_error
self.last_raw_result = result
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -590,7 +610,10 @@ class ToolUsage:
tool_name=sanitize_tool_name(tool.name),
attempts=self._run_attempts,
)
result = self._format_result(result=result)
self.last_raw_result = result
result = self._format_result(
result=tool.format_output_for_agent(result)
)
data = {
"result": result,
"tool_name": sanitize_tool_name(tool.name),
@@ -652,6 +675,7 @@ class ToolUsage:
result = ToolUsageError(
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
self.last_raw_result = result
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -661,7 +685,10 @@ class ToolUsage:
self.task.increment_tools_errors()
should_retry = True
else:
result = self._format_result(result=result)
self.last_raw_result = result
result = self._format_result(
result=tool.format_output_for_agent(result)
)
finally:
if started_event_emitted and not error_event_emitted:

View File

@@ -1383,6 +1383,19 @@ class NativeToolCallResult:
tool_message: LLMMessage = field(default_factory=dict) # type: ignore[assignment]
def format_native_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
"""Format native tool output when a tool explicitly defines a formatter."""
formatter = inspect.getattr_static(tool, "format_output_for_agent", None)
if formatter is None:
return str(raw_result)
runtime_formatter = getattr(tool, "format_output_for_agent", None)
if not callable(runtime_formatter):
return str(raw_result)
return str(runtime_formatter(raw_result))
def execute_single_native_tool_call(
tool_call: Any,
*,
@@ -1456,18 +1469,24 @@ def execute_single_native_tool_call(
original_tool = tool
break
structured_tool: CrewStructuredTool | None = None
for structured in structured_tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
output_tool = original_tool or structured_tool
from_cache = False
input_str = json.dumps(args_dict) if args_dict else ""
result = "Tool not found"
raw_tool_result: Any = result
if tools_handler and tools_handler.cache:
if tools_handler and tools_handler.cache and output_tool is not None:
cached_result = tools_handler.cache.read(tool=func_name, input=input_str)
if cached_result is not None:
result = (
str(cached_result)
if not isinstance(cached_result, str)
else cached_result
)
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
from_cache = True
started_at = datetime.now()
@@ -1486,12 +1505,6 @@ def execute_single_native_tool_call(
track_delegation_if_needed(func_name, args_dict, task)
structured_tool: CrewStructuredTool | None = None
for structured in structured_tools or []:
if sanitize_tool_name(structured.name) == func_name:
structured_tool = structured
break
hook_blocked = False
before_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -1512,11 +1525,13 @@ def execute_single_native_tool_call(
error_event_emitted = False
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
elif not from_cache:
if func_name in available_functions:
if func_name in available_functions and output_tool is not None:
try:
tool_func = available_functions[func_name]
raw_result = tool_func(**args_dict)
raw_tool_result = raw_result
if tools_handler and tools_handler.cache:
should_cache = True
@@ -1529,11 +1544,10 @@ def execute_single_native_tool_call(
tool=func_name, input=input_str, output=raw_result
)
result = (
str(raw_result) if not isinstance(raw_result, str) else raw_result
)
result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
if task:
task.increment_tools_errors()
crewai_event_bus.emit(
@@ -1559,6 +1573,7 @@ def execute_single_native_tool_call(
task=task,
crew=crew,
tool_result=result,
raw_tool_result=raw_tool_result,
)
try:
for after_hook in get_after_tool_call_hooks():

View File

@@ -116,6 +116,7 @@ async def aexecute_tool_and_check_finality(
logger.log("error", f"Error in before_tool_call hook: {e}")
tool_result = await tool_usage.ause(tool_calling, agent_action.text)
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
after_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
@@ -125,6 +126,7 @@ async def aexecute_tool_and_check_finality(
task=task,
crew=crew,
tool_result=tool_result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()
@@ -234,6 +236,7 @@ def execute_tool_and_check_finality(
logger.log("error", f"Error in before_tool_call hook: {e}")
tool_result = tool_usage.use(tool_calling, agent_action.text)
raw_tool_result = tool_usage.get_last_raw_result(tool_result)
after_hook_context = ToolCallHookContext(
tool_name=sanitized_tool_name,
@@ -243,6 +246,7 @@ def execute_tool_and_check_finality(
task=task,
crew=crew,
tool_result=tool_result,
raw_tool_result=raw_tool_result,
)
after_hooks = get_after_tool_call_hooks()

View File

@@ -7,6 +7,7 @@ when the LLM supports it, across multiple providers.
from __future__ import annotations
from collections.abc import Generator
import json
import os
import threading
import time
@@ -20,7 +21,7 @@ from crewai import Agent, Crew, Task
from crewai.agents.parser import AgentFinish
from crewai.events import crewai_event_bus
from crewai.hooks import register_after_tool_call_hook, register_before_tool_call_hook
from crewai.hooks.tool_hooks import ToolCallHookContext
from crewai.hooks.tool_hooks import ToolCallHookContext, clear_after_tool_call_hooks
from crewai.llm import LLM
from crewai.tools.base_tool import BaseTool
@@ -1197,6 +1198,76 @@ class TestNativeToolCallingJsonParseError:
assert result["result"] == "ran: print(1)"
def test_typed_output_is_json_agent_text(self) -> None:
class SearchOutput(BaseModel):
query: str
score: float
class TypedSearchTool(BaseTool):
name: str = "typed_search"
description: str = "Search for information"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.8)
tool = TypedSearchTool()
executor = self._make_executor([tool])
from crewai.utilities.agent_utils import convert_tools_to_openai_schema
_, available_functions, _ = convert_tools_to_openai_schema([tool])
result = executor._execute_single_native_tool_call(
call_id="call_typed",
func_name="typed_search",
func_args='{"query": "crew"}',
available_functions=available_functions,
)
assert json.loads(result["result"]) == {"query": "crew", "score": 0.8}
def test_typed_output_after_hook_includes_raw_tool_result(self) -> None:
from crewai.utilities.agent_utils import convert_tools_to_openai_schema
class SearchOutput(BaseModel):
query: str
score: float
class TypedSearchTool(BaseTool):
name: str = "typed_search"
description: str = "Search for information"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.8)
seen_results: list[tuple[str | None, object]] = []
def after_hook(context: ToolCallHookContext) -> None:
seen_results.append((context.tool_result, context.raw_tool_result))
tool = TypedSearchTool()
executor = self._make_executor([tool])
_, available_functions, _ = convert_tools_to_openai_schema([tool])
clear_after_tool_call_hooks()
register_after_tool_call_hook(after_hook)
try:
result = executor._execute_single_native_tool_call(
call_id="call_typed",
func_name="typed_search",
func_args='{"query": "crew"}',
available_functions=available_functions,
)
finally:
clear_after_tool_call_hooks()
assert json.loads(result["result"]) == {"query": "crew", "score": 0.8}
assert seen_results == [
('{"query":"crew","score":0.8}', SearchOutput(query="crew", score=0.8))
]
def test_native_tool_loop_falls_back_when_provider_rejects_tools(self) -> None:
"""Unsupported native tools errors should continue through ReAct."""

View File

@@ -91,20 +91,24 @@ class TestToolCallHookContext:
assert context.task == mock_task
assert context.crew == mock_crew
assert context.tool_result is None
assert context.raw_tool_result is None
def test_context_with_result(self, mock_tool):
"""Test that context includes result when provided."""
tool_input = {"arg1": "value1"}
tool_result = "Test tool result"
raw_tool_result = {"value": 42}
context = ToolCallHookContext(
tool_name="test_tool",
tool_input=tool_input,
tool=mock_tool,
tool_result=tool_result,
raw_tool_result=raw_tool_result,
)
assert context.tool_result == tool_result
assert context.raw_tool_result == raw_tool_result
def test_tool_input_is_mutable_reference(self, mock_tool):
"""Test that modifying context.tool_input modifies the original dict."""

View File

@@ -19,6 +19,39 @@ from crewai.memory.types import (
)
def test_memory_analysis_llm_is_isolated_from_streaming_agent_llm(
tmp_path: Path,
) -> None:
"""Memory analysis should not share a mutable streaming LLM with the agent UI."""
from crewai.llms.base_llm import BaseLLM
from crewai.memory.unified_memory import Memory
from crewai.utilities.types import LLMMessage
class FakeStreamingLLM(BaseLLM):
def call(
self,
messages: str | list[LLMMessage],
tools: list[dict] | None = None,
callbacks: list | None = None,
available_functions: dict | None = None,
from_task: object | None = None,
from_agent: object | None = None,
response_model: type | None = None,
) -> str:
return ""
agent_llm = FakeStreamingLLM(model="fake-model", stream=True)
mem = Memory(
storage=str(tmp_path / "db"),
llm=agent_llm,
embedder=lambda texts: [[0.1] for _ in texts],
)
assert mem._llm is not agent_llm
assert mem._llm.stream is False
agent_llm.stream = True
assert mem._llm.stream is False
def test_memory_record_defaults() -> None:

View File

@@ -7,6 +7,7 @@ from pathlib import Path
import sys
import pytest
from pydantic import BaseModel
from crewai.llms.base_llm import BaseLLM
from crewai.project.json_loader import (
@@ -14,6 +15,7 @@ from crewai.project.json_loader import (
_looks_like_windows_absolute_path,
find_json_project_file,
load_agent,
load_agent_from_definition,
strip_jsonc_comments,
)
@@ -358,6 +360,30 @@ class TestLoadAgent:
load_agent(Path("/nonexistent/agent.json"))
class TestLoadAgentFromDefinition:
def test_resolves_response_format_from_project_module(self, tmp_path: Path):
(tmp_path / "models.py").write_text(
"from pydantic import BaseModel\n"
"class AnswerModel(BaseModel):\n"
" answer: str\n"
)
_, response_format = load_agent_from_definition(
{
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"input": "Summarize this",
"response_format": {"python": "models.AnswerModel"},
},
source="agent action",
project_root=tmp_path,
)
assert issubclass(response_format, BaseModel)
assert response_format.__name__ == "AnswerModel"
class TestResolveTools:
def test_unknown_tool_raises_with_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools

View File

@@ -631,7 +631,7 @@ class TestLegacyMethodOutputsRestore:
assert restored.method_outputs == ["first", "second"]
def test_restore_legacy_outputs_evaluates_expressions(self) -> None:
from crewai.flow.runtime._expressions import _expression_context
from crewai.flow.expressions import Expression
flow = Flow()
flow._method_outputs = ["legacy"]
@@ -642,17 +642,14 @@ class TestLegacyMethodOutputsRestore:
cfg = CheckpointConfig(restore_from=loc)
restored = Flow.from_checkpoint(cfg)
context = _expression_context(restored)
context = Expression._flow_context(restored)
assert context["outputs"] == {"": "legacy"}
def test_raw_legacy_outputs_remain_readable(self) -> None:
from crewai.flow.runtime._expressions import _expression_context
def test_raw_legacy_outputs_property_remains_readable(self) -> None:
flow = Flow()
flow._method_outputs = ["legacy"]
assert flow.method_outputs == ["legacy"]
assert _expression_context(flow)["outputs"] == {"": "legacy"}
class TestAgentCheckpoint:

View File

@@ -37,6 +37,8 @@ def test_flow_public_exports_are_explicit():
}
assert set(flow_definition.__all__) == {
"FlowActionDefinition",
"FlowAgentActionDefinition",
"FlowAtomicActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
@@ -44,16 +46,16 @@ def test_flow_public_exports_are_explicit():
"FlowCrewActionDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowDictStateDefinition",
"FlowEachActionDefinition",
"FlowEachInnerActionDefinition",
"FlowEachStepDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowJsonSchemaStateDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowPydanticStateDefinition",
"FlowScriptActionDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
"FlowUnknownStateDefinition",
@@ -62,6 +64,126 @@ def test_flow_public_exports_are_explicit():
assert "calculate_node_levels" not in flow_visualization.__all__
def test_flow_definition_json_schema_carries_reference_descriptions():
schema = flow_definition.FlowDefinition.json_schema()
defs = schema["$defs"]
assert schema["properties"]["schema"]["description"]
assert schema["properties"]["methods"]["description"]
assert "diagnostics" not in schema["properties"]
method_properties = defs["FlowMethodDefinition"]["properties"]
assert method_properties["do"]["description"] == "Action executed when this method runs."
assert "Trigger condition" in method_properties["listen"]["description"]
script_properties = defs["FlowScriptActionDefinition"]["properties"]
assert "trusted inline Python" in script_properties["call"]["description"]
assert "not interpolated" in script_properties["code"]["description"]
assert "not sandboxed" in script_properties["code"]["description"]
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert "Inline Agent definition" in agent_properties["with"]["description"]
assert "run an inline Agent" in agent_properties["call"]["description"]
state_schema = next(
branch
for branch in schema["properties"]["state"]["anyOf"]
if "discriminator" in branch
)
assert state_schema["discriminator"]["propertyName"] == "type"
assert state_schema["discriminator"]["mapping"] == {
"dict": "#/$defs/FlowDictStateDefinition",
"json_schema": "#/$defs/FlowJsonSchemaStateDefinition",
"pydantic": "#/$defs/FlowPydanticStateDefinition",
"unknown": "#/$defs/FlowUnknownStateDefinition",
}
dict_state_properties = defs["FlowDictStateDefinition"]["properties"]
assert dict_state_properties["type"]["description"]
assert "ref" not in dict_state_properties
json_schema_state_properties = defs["FlowJsonSchemaStateDefinition"]["properties"]
assert json_schema_state_properties["json_schema"]["description"]
assert "json_schema" in defs["FlowJsonSchemaStateDefinition"]["required"]
pydantic_state_properties = defs["FlowPydanticStateDefinition"]["properties"]
assert "Fallback JSON Schema" in pydantic_state_properties["json_schema"][
"description"
]
each_properties = defs["FlowEachActionDefinition"]["properties"]
assert "list to iterate" in each_properties["in"]["description"]
assert "Ordered steps" in each_properties["do"]["description"]
step_properties = defs["FlowEachStepDefinition"]["properties"]
assert "runs only if" in step_properties["if"]["description"]
def test_flow_definition_json_schema_carries_field_examples_only():
schema = flow_definition.FlowDefinition.json_schema()
defs = schema["$defs"]
for model_name in [
"FlowDefinition",
"FlowCodeActionDefinition",
"FlowToolActionDefinition",
"FlowAgentActionDefinition",
"FlowCrewActionDefinition",
"FlowExpressionActionDefinition",
"FlowScriptActionDefinition",
"FlowEachActionDefinition",
"FlowEachStepDefinition",
"FlowMethodDefinition",
"FlowDictStateDefinition",
"FlowJsonSchemaStateDefinition",
"FlowPydanticStateDefinition",
"FlowUnknownStateDefinition",
"FlowConfigDefinition",
"FlowPersistenceDefinition",
"FlowHumanFeedbackDefinition",
]:
model_schema = schema if model_name == "FlowDefinition" else defs[model_name]
assert "examples" not in model_schema
assert schema["properties"]["name"]["examples"] == ["ResearchFlow"]
assert schema["properties"]["schema"]["examples"] == ["crewai.flow/v1"]
assert schema["properties"]["methods"]["examples"][0]["seed"]["do"] == {
"call": "expression",
"expr": "state.topic",
}
script_properties = defs["FlowScriptActionDefinition"]["properties"]
assert script_properties["call"]["examples"] == ["script"]
assert "input.strip()" in script_properties["code"]["examples"][0]
assert script_properties["language"]["examples"] == ["python"]
action_properties = defs["FlowCodeActionDefinition"]["properties"]
assert action_properties["ref"]["examples"] == [
"my_project.flows:normalize_topic"
]
assert action_properties["with"]["examples"] == [{"topic": "${state.topic}"}]
agent_properties = defs["FlowAgentActionDefinition"]["properties"]
assert agent_properties["call"]["examples"] == ["agent"]
assert agent_properties["with"]["examples"][0]["input"] == "${state.question}"
each_properties = defs["FlowEachActionDefinition"]["properties"]
assert each_properties["in"]["examples"] == ["state.rows"]
assert each_properties["do"]["examples"][0][0]["name"] == "clean"
assert each_properties["do"]["examples"][0][0]["action"]["call"] == "script"
assert each_properties["do"]["examples"][0][1]["if"] == "outputs.clean != ''"
step_properties = defs["FlowEachStepDefinition"]["properties"]
assert step_properties["if"]["examples"] == ["item.kind == 'invoice'"]
method_properties = defs["FlowMethodDefinition"]["properties"]
assert method_properties["listen"]["examples"] == [
"seed",
{"or": ["approved", "revise"]},
]
assert method_properties["emit"]["examples"] == [["approved", "revise"]]
def test_flow_state_definition_uses_discriminated_branches():
definition = flow_definition.FlowDefinition.model_validate(
{
@@ -233,7 +355,7 @@ def test_flow_definition_maps_dsl_to_static_contract():
assert review.human_feedback.learn_strict is True
assert definition.methods["audit"].listen == {"and": ["begin", "process"]}
assert definition.diagnostics == []
assert "diagnostics" not in definition.to_dict()
def test_flow_definition_excludes_conversational_builtins_for_regular_flows():
@@ -315,7 +437,8 @@ def test_flow_definition_uses_collapsed_conversational_router_start():
assert methods["route_conversation"].router is True
def test_flow_definition_serializes_human_feedback_metadata():
def test_flow_definition_serializes_human_feedback_metadata(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.dsl._utils")
marker = object()
class MetadataFlow(Flow):
@@ -334,9 +457,9 @@ def test_flow_definition_serializes_human_feedback_metadata():
assert review.human_feedback is not None
assert review.human_feedback.metadata == {"ref": "builtins:dict"}
assert any(
diagnostic.code == "non_serializable_value"
and diagnostic.path == "methods.review.human_feedback.metadata"
for diagnostic in definition.diagnostics
"methods.review.human_feedback.metadata" in record.message
and "not fully serializable" in record.message
for record in caplog.records
)
definition.to_json()
@@ -481,14 +604,16 @@ def test_each_action_round_trips_json_and_yaml():
"in": "state.rows",
"do": [
{
"normalize": {
"name": "normalize",
"action": {
"call": "tool",
"ref": "my_tools:NormalizeRowTool",
"with": {"row": "${ item }"},
}
},
{
"save": {
"name": "save",
"action": {
"call": "code",
"ref": "my_flow:save_row",
"with": {
@@ -575,7 +700,6 @@ def test_flow_definition_allows_dynamic_router_emit():
definition = DynamicRouterFlow.flow_definition()
assert definition.methods["decide"].emit is None
assert definition.diagnostics == []
def test_flow_definition_infers_literal_router_emit():
@@ -728,16 +852,15 @@ def test_flow_definition_accepts_explicit_router_events():
assert definition.methods["decide"].emit == ["left", "right"]
def test_flow_definition_preserves_diagnostics_loaded_from_contract():
def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedDiagnosticsFlow",
"methods": {
"decision": {
"do": {"ref": "loaded_flows:LoadedDiagnosticsFlow.decision"},
"router": True,
"emit": ["continue"],
"begin": {
"do": {"ref": "loaded_flows:LoadedDiagnosticsFlow.begin"},
"start": True,
}
},
"diagnostics": [
@@ -757,13 +880,13 @@ def test_flow_definition_preserves_diagnostics_loaded_from_contract():
}
)
codes = [diagnostic.code for diagnostic in definition.diagnostics]
assert "serialized_warning" in codes
assert codes.count("router_without_trigger") == 1
assert "diagnostics" not in definition.to_dict()
def test_router_start_false_without_listen_reports_missing_trigger():
definition = flow_definition.FlowDefinition.from_dict(
def test_router_start_false_without_listen_logs_missing_trigger(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",
@@ -779,9 +902,10 @@ def test_router_start_false_without_listen_reports_missing_trigger():
)
assert any(
diagnostic.code == "router_without_trigger"
and diagnostic.path == "methods.decision"
for diagnostic in definition.diagnostics
record.levelno == logging.ERROR
and "router_without_trigger" in record.message
and "methods.decision" in record.message
for record in caplog.records
)
@@ -809,7 +933,7 @@ def test_router_human_feedback_preserves_existing_router_metadata():
assert method.human_feedback is not None
def test_dynamic_router_flow_definition_has_no_diagnostics():
def test_dynamic_router_flow_definition_allows_dynamic_emit():
class LazyDynamicRouterFlow(Flow):
@start()
def begin(self):
@@ -820,7 +944,7 @@ def test_dynamic_router_flow_definition_has_no_diagnostics():
return self.state["dynamic_event"]
definition = LazyDynamicRouterFlow.flow_definition()
assert definition.diagnostics == []
assert definition.methods["decide"].emit is None
def test_dynamic_router_string_listener_is_valid_contract():
@@ -839,7 +963,7 @@ def test_dynamic_router_string_listener_is_valid_contract():
definition = DynamicRouterListenerFlow.flow_definition()
assert definition.diagnostics == []
assert definition.methods["handle"].listen == "dynamic_event"
def test_static_string_listener_is_allowed_by_contract():
@@ -859,7 +983,7 @@ def test_static_string_listener_is_allowed_by_contract():
},
}
)
assert definition.diagnostics == []
assert definition.methods["handle"].listen == "begni"
def test_start_false_not_classified_as_start_method():
@@ -924,10 +1048,10 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
assert set(child_definition.methods) == {"child_step"}
def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
definition = flow_definition.FlowDefinition.from_dict(
flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",
@@ -941,10 +1065,6 @@ def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
}
)
assert any(
diagnostic.code == "router_without_trigger"
for diagnostic in definition.diagnostics
)
assert any(
record.levelno == logging.ERROR
and "LoadedFlow" in record.message

View File

@@ -26,6 +26,7 @@ from crewai.flow.flow_config import flow_config
from crewai.flow.flow_definition import FlowConfigDefinition, FlowDefinition
from crewai.flow.persistence import persist
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.tools import BaseTool
from crewai.types.streaming import FlowStreamingOutput
@@ -113,7 +114,7 @@ class EachActionFlow(Flow):
except RuntimeError:
pass
else:
raise RuntimeError("inner action ran on the event loop")
raise RuntimeError("each step ran on the event loop")
from crewai.flow.flow_context import current_flow_method_name
@@ -643,7 +644,7 @@ methods:
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
def test_tool_action_rejects_braces_in_embedded_cel_input():
def test_tool_action_treats_embedded_cel_marker_as_literal():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
@@ -659,16 +660,62 @@ def test_tool_action_rejects_braces_in_embedded_cel_input():
"prefix": "${'p}x'}",
},
},
}
},
},
}
)
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
assert Flow.from_definition(definition).kickoff() == "p}x:wrapped ${'a}b'} value"
def test_tool_action_rejects_braces_in_full_cel_input():
def test_tool_action_treats_marker_with_trailing_text_as_literal():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${state.topic} extra",
"prefix": "p",
},
},
},
},
}
)
assert Flow.from_definition(definition).kickoff() == "p:${state.topic} extra"
def test_tool_action_rejects_adjacent_markers_as_invalid_cel():
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${'a'}${'b'}",
"prefix": "p",
},
},
},
},
}
)
def test_tool_action_accepts_braces_in_full_cel_marker():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
@@ -681,16 +728,15 @@ def test_tool_action_rejects_braces_in_full_cel_input():
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${{'query': 'ai agents'}.query}",
"prefix": "found",
"prefix": "${'p}x'}",
},
},
}
},
},
}
)
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
assert Flow.from_definition(definition).kickoff() == "p}x:ai agents"
def test_tool_action_renders_latest_output_by_method_name():
@@ -765,6 +811,166 @@ methods:
)
def test_agent_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
from crewai import Agent
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> dict[str, Any]:
return {"agent": self.role, "input": messages}
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: AgentFlow
methods:
answer:
do:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${state.question}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == {
"agent": "Analyst",
"input": "What is CrewAI?",
}
def test_agent_action_runs_inside_each(monkeypatch: pytest.MonkeyPatch):
from crewai import Agent
async def fake_kickoff_async(
self: Agent, messages: str, **_kwargs: Any
) -> str:
return f"{self.role}:{messages}"
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: AgentEachFlow
methods:
answer_each:
do:
call: each
in: state.questions
do:
- name: answer
action:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${item}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [
"Analyst:one",
"Analyst:two",
]
def test_agent_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
"methods": {
"answer": {
"start": True,
"do": {
"call": "agent",
"with": {
"role": "Analyst",
"goal": "Answer questions",
"backstory": "Knows things.",
"settings": {"verbose": True},
"input": "${state.question}",
},
},
}
},
}
)
round_trip = FlowDefinition.from_yaml(definition.to_yaml())
action = round_trip.to_dict()["methods"]["answer"]["do"]
assert action["call"] == "agent"
assert action["with"]["role"] == "Analyst"
assert action["with"]["input"] == "${state.question}"
assert action["with"]["settings"] == {"verbose": True}
def test_agent_action_json_schema_describes_inline_agent_definitions():
schema_defs = FlowDefinition.json_schema()["$defs"]
assert set(schema_defs["AgentDefinition"]["properties"]) >= {
"role",
"goal",
"backstory",
"settings",
"input",
"response_format",
}
def test_agent_action_rejects_non_string_input_in_definition():
with pytest.raises(ValidationError, match="agent.input must be a string"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
"methods": {
"answer": {
"start": True,
"do": {
"call": "agent",
"with": {
"role": "Analyst",
"goal": "Answer questions",
"backstory": "Knows things.",
"input": 123,
},
},
}
},
}
)
def test_agent_action_reports_invalid_cel_expression():
yaml_str = """
schema: crewai.flow/v1
name: AgentFlow
methods:
answer:
do:
call: agent
with:
role: Analyst
goal: Answer questions
backstory: Knows things.
input: "${state.}"
start: true
"""
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
def test_crew_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
from crewai import Crew
@@ -1025,10 +1231,8 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
def test_code_action_renders_keyword_inputs():
@@ -1080,7 +1284,8 @@ methods:
call: each
in: state.rows
do:
- normalize:
- name: normalize
action:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
@@ -1096,7 +1301,7 @@ methods:
]
def test_each_action_runs_sync_inner_actions_off_event_loop_with_context():
def test_each_action_runs_sync_steps_off_event_loop_with_context():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1106,7 +1311,8 @@ methods:
call: each
in: state.rows
do:
- threaded:
- name: threaded
action:
call: code
ref: {__name__}:EachActionFlow.require_threaded_context
with:
@@ -1122,7 +1328,7 @@ methods:
assert flow.inner_thread_id != caller_thread_id
def test_each_action_runs_async_tool_results_from_sync_inner_actions():
def test_each_action_runs_async_tool_results_from_sync_steps():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1132,7 +1338,8 @@ methods:
call: each
in: state.rows
do:
- async_tool:
- name: async_tool
action:
call: tool
ref: {__name__}:AsyncResultTool
with:
@@ -1145,7 +1352,120 @@ methods:
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
def test_each_action_uses_iteration_outputs_between_nested_actions():
def test_script_action_requires_explicit_opt_in():
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
normalize:
do:
call: script
code: |
return "blocked"
start: true
"""
with pytest.raises(
FlowScriptExecutionDisabledError,
match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1",
) as exc_info:
Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert "methods with unresolvable actions" not in str(exc_info.value)
def test_script_action_runs_python_imports_mutates_state_and_returns_value(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
normalize:
do:
call: script
code: |
import math
state["rounded"] = math.ceil(state["raw_score"])
return f"rounded:{state['rounded']}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4"
assert flow.state["rounded"] == 4
def test_script_listener_reads_trigger_input_and_outputs(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptFlow
methods:
seed:
do:
call: expression
expr: "'alpha'"
start: true
combine:
do:
call: script
code: |
state["input_matches_output"] = input == outputs["seed"]
return f"{outputs['seed']}:{input}"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff() == "alpha:alpha"
assert flow.state["input_matches_output"] is True
def test_script_each_action_reads_item_and_step_outputs(
monkeypatch: pytest.MonkeyPatch,
):
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
yaml_str = """
schema: crewai.flow/v1
name: ScriptEachFlow
methods:
seed:
do:
call: expression
expr: "'global'"
start: true
process_rows:
do:
call: each
in: state.rows
do:
- name: clean
action:
call: script
code: |
return item.strip()
- name: tag
action:
call: script
code: |
return f"{outputs['seed']}:{outputs['clean']}"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
def test_each_action_uses_iteration_outputs_between_steps():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
@@ -1155,13 +1475,15 @@ methods:
call: each
in: state.rows
do:
- normalize:
- name: normalize
action:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
prefix: saved
- save:
- name: save
action:
call: code
ref: {__name__}:EachActionFlow.save_row
with:
@@ -1178,7 +1500,7 @@ methods:
]
def test_each_action_resets_inner_outputs_between_iterations():
def test_each_action_resets_step_outputs_between_iterations():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
@@ -1188,10 +1510,12 @@ methods:
call: each
in: state.rows
do:
- leak_check:
- name: leak_check
action:
call: expression
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
- previous:
- name: previous
action:
call: expression
expr: item
start: true
@@ -1205,7 +1529,7 @@ methods:
]
def test_each_action_preserves_flow_outputs_and_prefers_inner_outputs():
def test_each_action_preserves_flow_outputs_and_prefers_step_outputs():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
@@ -1220,13 +1544,16 @@ methods:
call: each
in: state.rows
do:
- before_shadow:
- name: before_shadow
action:
call: expression
expr: "outputs.seed + ':' + item"
- seed:
- name: seed
action:
call: expression
expr: "'local:' + item"
- after_shadow:
- name: after_shadow
action:
call: expression
expr: "outputs.seed"
listen: seed
@@ -1244,6 +1571,130 @@ methods:
]
def test_each_action_runs_simple_if_clauses():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: kind
action:
call: expression
expr: item.kind
- name: kept
if: "outputs.kind == 'keep'"
action:
call: expression
expr: "'kept:' + item.value"
- name: skipped
if: "outputs.kind != 'keep'"
action:
call: expression
expr: "'skipped:' + item.value"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(
inputs={
"rows": [
{"kind": "keep", "value": "a"},
{"kind": "drop", "value": "b"},
]
}
) == ["kept:a", "skipped:b"]
def test_each_action_accepts_expression_markers_in_explicit_cel_fields():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: "${state.rows}"
do:
- name: kind
action:
call: expression
expr: "${item.kind}"
- name: kept
if: "${outputs.kind == 'keep'}"
action:
call: expression
expr: "${item.value}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"]
def test_each_action_skipped_if_keeps_previous_output():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: original
action:
call: expression
expr: item.value
- name: maybe_included
if: item.include
action:
call: expression
expr: "'included:' + item.value"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(
inputs={
"rows": [
{"include": True, "value": "a"},
{"include": False, "value": "b"},
]
}
) == ["included:a", "b"]
def test_each_action_if_condition_must_be_boolean():
yaml_str = """
schema: crewai.flow/v1
name: EachIfFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- name: value
if: item.value
action:
call: expression
expr: item.value
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
def test_each_action_empty_list_returns_empty_and_listener_runs_once():
yaml_str = f"""
schema: crewai.flow/v1
@@ -1254,7 +1705,8 @@ methods:
call: each
in: state.rows
do:
- normalize:
- name: normalize
action:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
@@ -1303,7 +1755,12 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
"do": {
"call": "each",
"in": expr,
"do": [{"value": {"call": "expression", "expr": "item"}}],
"do": [
{
"name": "value",
"action": {"call": "expression", "expr": "item"},
}
],
},
}
},
@@ -1319,15 +1776,25 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
"action_do",
[
[],
[{"first": {"call": "expression", "expr": "item"}, "second": {"call": "expression", "expr": "item"}}],
[{"1bad": {"call": "expression", "expr": "item"}}],
[{"value": {"call": "expression", "expr": "item"}}],
[{"name": "1bad", "action": {"call": "expression", "expr": "item"}}],
[{"name": "missing_action"}],
[{"action": {"call": "expression", "expr": "item"}}],
[
{"same": {"call": "expression", "expr": "item"}},
{"same": {"call": "expression", "expr": "item"}},
{
"name": "value",
"if": "true",
"then": [],
"action": {"call": "expression", "expr": "item"},
}
],
[
{"name": "same", "action": {"call": "expression", "expr": "item"}},
{"name": "same", "action": {"call": "expression", "expr": "item"}},
],
],
)
def test_each_action_validates_inner_action_shape(action_do):
def test_each_action_validates_step_shape(action_do):
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
@@ -1347,6 +1814,26 @@ def test_each_action_validates_inner_action_shape(action_do):
)
def test_if_clauses_are_rejected_at_method_level():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "TopLevelIfFlow",
"methods": {
"process": {
"start": True,
"do": {
"call": "expression",
"if": "true",
"expr": "'ok'",
},
}
},
}
)
def test_each_action_rejects_nested_each_actions():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
@@ -1361,12 +1848,14 @@ def test_each_action_rejects_nested_each_actions():
"in": "state.rows",
"do": [
{
"nested": {
"name": "nested",
"action": {
"call": "each",
"in": "state.children",
"do": [
{
"child": {
"name": "child",
"action": {
"call": "expression",
"expr": "item",
}
@@ -1392,7 +1881,8 @@ methods:
call: each
in: state.rows
do:
- validate:
- name: validate
action:
call: code
ref: {__name__}:EachActionFlow.fail_on_bad_row
with:
@@ -1430,8 +1920,28 @@ def test_expression_action_round_trips():
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_explicit_cel_fields_accept_expression_markers():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
"methods": {
"classify": {
"start": True,
"do": {
"call": "expression",
"expr": "${state.score >= 80 ? 'qualified' : 'nurture'}",
},
}
},
}
)
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_local_context_recurses_into_dataclass_values():
from crewai.flow.runtime._expressions import evaluate_expression
from crewai.flow.expressions import Expression
class Payload(BaseModel):
name: str
@@ -1441,15 +1951,37 @@ def test_expression_local_context_recurses_into_dataclass_values():
payload: Payload
assert (
evaluate_expression(
Flow(),
Expression.from_flow(
"item.payload.name",
Flow(),
local_context={"item": Row(payload=Payload(name="qualified"))},
)
).evaluate()
== "qualified"
)
def test_expression_empty_context_overrides_stored_context():
from crewai.flow.expressions import Expression, ExpressionError
expression = Expression("state.score", context={"state": {"score": 90}})
assert expression.evaluate() == 90
with pytest.raises(ExpressionError):
expression.evaluate({})
def test_expression_template_empty_context_overrides_stored_context():
from crewai.flow.expressions import Expression, ExpressionError
expression = Expression(
{"score": "${state.score}"}, context={"state": {"score": 90}}
)
assert expression.render_template() == {"score": 90}
with pytest.raises(ExpressionError):
expression.render_template({})
def test_expression_action_can_route_like_if_else():
yaml_str = f"""
schema: crewai.flow/v1
@@ -1501,10 +2033,24 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_yaml(yaml_str)
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
def test_expression_action_rejects_unknown_cel_root():
yaml_str = """
schema: crewai.flow/v1
name: ExpressionFlow
methods:
classify:
do:
call: expression
expr: "score >= 80"
start: true
"""
with pytest.raises(ValidationError, match="unknown CEL root"):
FlowDefinition.from_yaml(yaml_str)
def test_tool_action_requires_module_qualname_ref():

View File

@@ -1,12 +1,13 @@
import asyncio
from collections.abc import Callable
import json
from unittest.mock import patch
from crewai.agent import Agent
from crewai.crew import Crew
from crewai.task import Task
from crewai.tools import BaseTool, tool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, RootModel
import pytest
@@ -351,6 +352,262 @@ class TestToolDecoratorRunValidation:
assert result == "Hello, World!"
class SearchOutput(BaseModel):
query: str
score: float
class SearchResults(RootModel[list[SearchOutput]]):
pass
class ExplicitSearchTool(BaseTool):
name: str = "search"
description: str = "Search for a query"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> dict[str, object]:
return {"query": query, "score": 0.8}
class InferredSearchTool(BaseTool):
name: str = "search"
description: str = "Search for a query"
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.7)
class RootSearchTool(BaseTool):
name: str = "search"
description: str = "Search for a query"
def _run(self, query: str) -> SearchResults:
return SearchResults([SearchOutput(query=query, score=1.0)])
class DictAnnotatedSearchTool(BaseTool):
name: str = "search"
description: str = "Search for a query"
def _run(self, query: str) -> dict[str, object]:
return {"query": query, "score": 0.5}
def _make_explicit_decorator_tool() -> BaseTool:
@tool("search", result_schema=SearchOutput)
def search(query: str) -> dict[str, object]:
"""Search for a query."""
return {"query": query, "score": 0.8}
return search
def _make_inferred_decorator_tool() -> BaseTool:
@tool("search")
def search(query: str) -> SearchOutput:
"""Search for a query."""
return SearchOutput(query=query, score=0.6)
return search
def _make_root_decorator_tool() -> BaseTool:
@tool("search")
def search(query: str) -> SearchResults:
"""Search for a query."""
return SearchResults([SearchOutput(query=query, score=1.0)])
return search
class TestToolOutputSchema:
@pytest.mark.parametrize(
("tool_cls", "expected_raw", "expected_agent_payload"),
[
pytest.param(
ExplicitSearchTool,
{"query": "crew", "score": 0.8},
{"query": "crew", "score": 0.8},
id="explicit-schema",
),
pytest.param(
InferredSearchTool,
SearchOutput(query="crew", score=0.7),
{"query": "crew", "score": 0.7},
id="inferred-base-model",
),
pytest.param(
RootSearchTool,
SearchResults([SearchOutput(query="crew", score=1.0)]),
[{"query": "crew", "score": 1.0}],
id="inferred-root-model",
),
],
)
def test_base_tools_return_raw_result_and_json_agent_text(
self,
tool_cls: type[BaseTool],
expected_raw: object,
expected_agent_payload: object,
) -> None:
t = tool_cls()
raw_result = t.run(query="crew")
assert raw_result == expected_raw
assert json.loads(t.format_output_for_agent(raw_result)) == (
expected_agent_payload
)
def test_base_tool_does_not_infer_non_pydantic_return_annotation(self) -> None:
t = DictAnnotatedSearchTool()
raw_result = t.run(query="crew")
assert raw_result == {"query": "crew", "score": 0.5}
assert t.format_output_for_agent(raw_result) == str(raw_result)
@pytest.mark.parametrize(
("make_tool", "expected_raw", "expected_agent_payload"),
[
pytest.param(
_make_explicit_decorator_tool,
{"query": "crew", "score": 0.8},
{"query": "crew", "score": 0.8},
id="explicit-schema",
),
pytest.param(
_make_inferred_decorator_tool,
SearchOutput(query="crew", score=0.6),
{"query": "crew", "score": 0.6},
id="inferred-base-model",
),
pytest.param(
_make_root_decorator_tool,
SearchResults([SearchOutput(query="crew", score=1.0)]),
[{"query": "crew", "score": 1.0}],
id="inferred-root-model",
),
],
)
def test_decorator_tools_return_raw_result_and_json_agent_text(
self,
make_tool: Callable[[], BaseTool],
expected_raw: object,
expected_agent_payload: object,
) -> None:
search = make_tool()
raw_result = search.run(query="crew")
assert raw_result == expected_raw
assert json.loads(search.format_output_for_agent(raw_result)) == (
expected_agent_payload
)
def test_decorator_tool_does_not_infer_non_pydantic_return_annotation(
self,
) -> None:
@tool("search")
def search(query: str) -> dict[str, object]:
"""Search for a query."""
return {"query": query, "score": 0.5}
raw_result = search.run(query="crew")
assert raw_result == {"query": "crew", "score": 0.5}
assert search.format_output_for_agent(raw_result) == str(raw_result)
def test_explicit_result_schema_wins_over_return_annotation(self) -> None:
class AlternateOutput(BaseModel):
value: str
@tool("search", result_schema=AlternateOutput)
def search(query: str) -> SearchOutput:
"""Search for a query."""
return SearchOutput(query=query, score=0.6)
raw_result = search.run(query="crew")
with pytest.warns(RuntimeWarning, match="AlternateOutput"):
agent_text = search.format_output_for_agent(raw_result)
assert raw_result == SearchOutput(query="crew", score=0.6)
assert agent_text == str(raw_result)
def test_invalid_typed_output_warns_and_uses_string_agent_text(
self,
) -> None:
@tool("search", result_schema=SearchOutput)
def search(query: str) -> dict[str, object]:
"""Search for a query."""
return {"query": query, "score": "not-a-float"}
raw_result = search.run(query="crew")
with pytest.warns(RuntimeWarning, match="Failed to validate or serialize"):
agent_text = search.format_output_for_agent(raw_result)
assert raw_result == {"query": "crew", "score": "not-a-float"}
assert agent_text == str(raw_result)
def test_unserializable_typed_output_warns_and_uses_string_agent_text(
self,
) -> None:
class OpaqueOutput(BaseModel):
value: object
raw_result = OpaqueOutput(value=object())
@tool("opaque", result_schema=OpaqueOutput)
def opaque() -> OpaqueOutput:
"""Return an opaque object."""
return raw_result
result = opaque.run()
with pytest.warns(RuntimeWarning, match="Failed to validate or serialize"):
agent_text = opaque.format_output_for_agent(result)
assert result is raw_result
assert agent_text == str(raw_result)
def test_result_schema_behavior_carries_over_to_structured_tool(self) -> None:
structured = ExplicitSearchTool().to_structured_tool()
raw_result = structured.invoke({"query": "crew"})
assert raw_result == {"query": "crew", "score": 0.8}
assert json.loads(structured.format_output_for_agent(raw_result)) == {
"query": "crew",
"score": 0.8,
}
def test_custom_agent_output_formatter_carries_over_to_structured_tool(
self,
) -> None:
class MarkdownSearchTool(BaseTool):
name: str = "markdown_search"
description: str = "Search for information"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.8)
def format_output_for_agent(self, raw_result: object) -> str:
result = self.result_schema.model_validate(raw_result)
return f"### Search result\n\n- Query: `{result.query}`\n- Score: {result.score}"
structured = MarkdownSearchTool().to_structured_tool()
raw_result = structured.invoke({"query": "crew"})
assert raw_result == SearchOutput(query="crew", score=0.8)
assert structured.format_output_for_agent(raw_result) == (
"### Search result\n\n- Query: `crew`\n- Score: 0.8"
)
# Async arun() Schema Validation Tests

View File

@@ -1,5 +1,7 @@
import json
from crewai.tools.structured_tool import CrewStructuredTool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, RootModel
import pytest
@@ -86,6 +88,118 @@ def test_from_function(basic_function):
assert isinstance(tool.args_schema, type(BaseModel))
class StructuredOutput(BaseModel):
value: str
count: int
class StructuredOutputList(RootModel[list[StructuredOutput]]):
pass
def _build_explicit_structured_value(value: str) -> dict[str, object]:
"""Build a value."""
return {"value": value, "count": 1}
def _build_inferred_structured_value(value: str) -> StructuredOutput:
"""Build a value."""
return StructuredOutput(value=value, count=1)
def _build_structured_values(value: str) -> StructuredOutputList:
"""Build values."""
return StructuredOutputList([StructuredOutput(value=value, count=1)])
def _build_plain_structured_value(value: str) -> dict[str, object]:
"""Build a value."""
return {"value": value, "count": 1}
@pytest.mark.parametrize(
("func", "result_schema", "expected_raw", "expected_agent_payload"),
[
pytest.param(
_build_explicit_structured_value,
StructuredOutput,
{"value": "crew", "count": 1},
{"value": "crew", "count": 1},
id="explicit-schema",
),
pytest.param(
_build_inferred_structured_value,
None,
StructuredOutput(value="crew", count=1),
{"value": "crew", "count": 1},
id="inferred-base-model",
),
pytest.param(
_build_structured_values,
None,
StructuredOutputList([StructuredOutput(value="crew", count=1)]),
[{"value": "crew", "count": 1}],
id="inferred-root-model",
),
],
)
def test_from_function_returns_raw_result_and_json_agent_text(
func,
result_schema,
expected_raw,
expected_agent_payload,
):
kwargs = {"result_schema": result_schema} if result_schema is not None else {}
tool = CrewStructuredTool.from_function(
func=func,
name="build_value",
**kwargs,
)
raw_result = tool.invoke({"value": "crew"})
assert raw_result == expected_raw
assert json.loads(tool.format_output_for_agent(raw_result)) == (
expected_agent_payload
)
def test_from_function_does_not_infer_non_pydantic_result_schema():
tool = CrewStructuredTool.from_function(
func=_build_plain_structured_value,
name="build_value",
)
raw_result = tool.invoke({"value": "crew"})
assert raw_result == {"value": "crew", "count": 1}
assert tool.format_output_for_agent(raw_result) == str(raw_result)
def test_invalid_typed_output_warns_and_uses_string_agent_text():
def build_value(value: str) -> dict[str, object]:
"""Build a value."""
return {"value": value, "count": "wrong"}
tool = CrewStructuredTool.from_function(
func=build_value,
name="build_value",
result_schema=StructuredOutput,
)
raw_result = tool.invoke({"value": "crew"})
with pytest.warns(
RuntimeWarning, match="Failed to validate or serialize"
) as warnings:
agent_text = tool.format_output_for_agent(raw_result)
assert raw_result == {"value": "crew", "count": "wrong"}
assert agent_text == str(raw_result)
warning_message = str(warnings[0].message)
assert "ValidationError" in warning_message
assert "wrong" not in warning_message
def test_validate_function_signature(basic_function, schema_class):
"""Test function signature validation"""
tool = CrewStructuredTool(

View File

@@ -1,4 +1,5 @@
import datetime
from collections.abc import Callable
import json
import random
import threading
@@ -6,6 +7,9 @@ import time
from unittest.mock import MagicMock, patch
from crewai import Agent, Task
from crewai.agents.cache.cache_handler import CacheHandler
from crewai.agents.parser import AgentAction
from crewai.agents.tools_handler import ToolsHandler
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import (
ToolSelectionErrorEvent,
@@ -14,8 +18,15 @@ from crewai.events.types.tool_usage_events import (
ToolUsageStartedEvent,
ToolValidateInputErrorEvent,
)
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
clear_after_tool_call_hooks,
register_after_tool_call_hook,
)
from crewai.tools import BaseTool
from crewai.tools.tool_calling import ToolCalling
from crewai.tools.tool_usage import ToolUsage
from crewai.utilities.tool_utils import execute_tool_and_check_finality
from pydantic import BaseModel, Field
import pytest
@@ -38,6 +49,19 @@ class RandomNumberTool(BaseTool):
return random.randint(min_value, max_value) # noqa: S311
class SearchOutput(BaseModel):
query: str
score: float
class TypedSearchTool(BaseTool):
name: str = "typed_search"
description: str = "Search for a query"
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.7)
# Example agent and task
example_agent = Agent(
role="Number Generator",
@@ -117,6 +141,126 @@ def test_tool_usage_render():
assert '"description": "The maximum value of the range (inclusive)"' in rendered
def test_tool_usage_returns_json_agent_text_for_typed_output():
tool = TypedSearchTool().to_structured_tool()
tool_usage = ToolUsage(
tools_handler=None,
tools=[tool],
task=None,
function_calling_llm=MagicMock(),
agent=None,
action=MagicMock(),
)
result = tool_usage.use(
calling=ToolCalling(
tool_name="typed_search",
arguments={"query": "crew"},
),
tool_string='Action: typed_search\nAction Input: {"query": "crew"}',
)
assert json.loads(result) == {"query": "crew", "score": 0.7}
def test_tool_usage_cache_callback_receives_raw_typed_output():
raw_results: list[object] = []
def cache_result(_args: object, result: object) -> bool:
raw_results.append(result)
return True
class CacheAwareTypedSearchTool(TypedSearchTool):
cache_function: Callable = cache_result
tools_handler = MagicMock()
tools_handler.cache = None
tools_handler.last_used_tool = None
tool = CacheAwareTypedSearchTool().to_structured_tool()
tool_usage = ToolUsage(
tools_handler=tools_handler,
tools=[tool],
task=None,
function_calling_llm=MagicMock(),
agent=None,
action=MagicMock(),
)
result = tool_usage.use(
calling=ToolCalling(
tool_name="typed_search",
arguments={"query": "crew"},
),
tool_string='Action: typed_search\nAction Input: {"query": "crew"}',
)
assert json.loads(result) == {"query": "crew", "score": 0.7}
assert raw_results == [SearchOutput(query="crew", score=0.7)]
tools_handler.on_tool_use.assert_called_once()
assert tools_handler.on_tool_use.call_args.kwargs["output"] == SearchOutput(
query="crew",
score=0.7,
)
def test_react_tool_hooks_receive_agent_text_and_raw_cached_typed_output():
structured_tool = TypedSearchTool().to_structured_tool()
tools_handler = ToolsHandler(cache=CacheHandler())
seen_results: list[tuple[str | None, object]] = []
def after_hook(context: ToolCallHookContext) -> None:
seen_results.append((context.tool_result, context.raw_tool_result))
clear_after_tool_call_hooks()
register_after_tool_call_hook(after_hook)
action = AgentAction(
thought="",
tool="typed_search",
tool_input='{"query": "crew"}',
text='Action: typed_search\nAction Input: {"query": "crew"}',
)
try:
first = execute_tool_and_check_finality(
agent_action=action,
tools=[structured_tool],
tools_handler=tools_handler,
)
tools_handler.last_used_tool = None
second = execute_tool_and_check_finality(
agent_action=action,
tools=[structured_tool],
tools_handler=tools_handler,
)
finally:
clear_after_tool_call_hooks()
assert json.loads(first.result) == {"query": "crew", "score": 0.7}
assert json.loads(second.result) == {"query": "crew", "score": 0.7}
assert seen_results == [
('{"query":"crew","score":0.7}', SearchOutput(query="crew", score=0.7)),
('{"query":"crew","score":0.7}', SearchOutput(query="crew", score=0.7)),
]
def test_last_raw_result_falls_back_only_until_recorded():
tool_usage = ToolUsage(
tools_handler=None,
tools=[],
task=None,
function_calling_llm=MagicMock(),
agent=None,
action=MagicMock(),
)
assert tool_usage.get_last_raw_result("formatted result") == "formatted result"
tool_usage.last_raw_result = None
assert tool_usage.get_last_raw_result("formatted result") is None
def test_validate_tool_input_booleans_and_none():
tool_usage = ToolUsage(
tools_handler=MagicMock(),

View File

@@ -3,12 +3,19 @@
from __future__ import annotations
import asyncio
import json
from typing import Any, Literal, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel, Field
from crewai.hooks.tool_hooks import (
ToolCallHookContext,
clear_after_tool_call_hooks,
clear_before_tool_call_hooks,
register_after_tool_call_hook,
)
from crewai.tools.base_tool import BaseTool
from crewai.utilities.agent_utils import (
_asummarize_chunks,
@@ -1030,6 +1037,142 @@ class TestParseToolCallArgs:
class TestExecuteSingleNativeToolCall:
"""Tests for execute_single_native_tool_call."""
def test_typed_tool_output_is_json_agent_text(self) -> None:
clear_before_tool_call_hooks()
clear_after_tool_call_hooks()
class SearchOutput(BaseModel):
query: str
score: float
class TypedSearchTool(BaseTool):
name: str = "typed_search"
description: str = "Search for a query"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.9)
tool = TypedSearchTool()
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "typed_search"
tool_call.function.arguments = '{"query": "crew"}'
result = execute_single_native_tool_call(
tool_call,
available_functions={"typed_search": tool._run},
original_tools=[tool],
structured_tools=[tool.to_structured_tool()],
tools_handler=None,
agent=None,
task=None,
crew=None,
event_source=MagicMock(),
printer=None,
verbose=False,
)
assert json.loads(result.result) == {"query": "crew", "score": 0.9}
assert json.loads(result.tool_message["content"]) == {
"query": "crew",
"score": 0.9,
}
def test_custom_agent_output_formatter_is_used_from_structured_tool(
self,
) -> None:
clear_before_tool_call_hooks()
clear_after_tool_call_hooks()
class SearchOutput(BaseModel):
query: str
score: float
class MarkdownSearchTool(BaseTool):
name: str = "markdown_search"
description: str = "Search for a query"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.9)
def format_output_for_agent(self, raw_result: Any) -> str:
result = self.result_schema.model_validate(raw_result)
return f"### {result.query}\n\nScore: **{result.score}**"
tool = MarkdownSearchTool()
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "markdown_search"
tool_call.function.arguments = '{"query": "crew"}'
result = execute_single_native_tool_call(
tool_call,
available_functions={"markdown_search": tool._run},
original_tools=[],
structured_tools=[tool.to_structured_tool()],
tools_handler=None,
agent=None,
task=None,
crew=None,
event_source=MagicMock(),
printer=None,
verbose=False,
)
assert result.result == "### crew\n\nScore: **0.9**"
assert result.tool_message["content"] == "### crew\n\nScore: **0.9**"
def test_after_hook_includes_raw_tool_result_for_typed_output(self) -> None:
clear_after_tool_call_hooks()
class SearchOutput(BaseModel):
query: str
score: float
class TypedSearchTool(BaseTool):
name: str = "typed_search"
description: str = "Search for a query"
result_schema: type[BaseModel] = SearchOutput
def _run(self, query: str) -> SearchOutput:
return SearchOutput(query=query, score=0.9)
seen_results: list[tuple[str | None, object]] = []
def after_hook(context: ToolCallHookContext) -> None:
seen_results.append((context.tool_result, context.raw_tool_result))
tool = TypedSearchTool()
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "typed_search"
tool_call.function.arguments = '{"query": "crew"}'
register_after_tool_call_hook(after_hook)
try:
result = execute_single_native_tool_call(
tool_call,
available_functions={"typed_search": tool._run},
original_tools=[tool],
structured_tools=[tool.to_structured_tool()],
tools_handler=None,
agent=None,
task=None,
crew=None,
event_source=MagicMock(),
printer=None,
verbose=False,
)
finally:
clear_after_tool_call_hooks()
assert json.loads(result.result) == {"query": "crew", "score": 0.9}
assert seen_results == [
('{"query":"crew","score":0.9}', SearchOutput(query="crew", score=0.9))
]
def test_result_as_answer_false_on_tool_error(self) -> None:
"""When a tool with result_as_answer=True raises, result_as_answer must be False.

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.7"
__version__ = "1.14.8a2"

View File

@@ -171,6 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0."
[tool.uv]
exclude-newer = "3 days"
# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff.
exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" }
# composio-core pins rich<14 but textual requires rich>=14.
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
@@ -180,7 +182,8 @@ exclude-newer = "3 days"
# langchain-text-splitters <1.1.2 has GHSA-fv5p-p927-qmxr (SSRF bypass in split_text_from_url).
# transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1).
# cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+.
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p; force 6.10.2+.
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p.
# pypdf <6.13.3 has GHSA-jm82-fx9c-mx94; force 6.13.3+.
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
# 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).
@@ -205,7 +208,7 @@ override-dependencies = [
"urllib3>=2.7.0",
"transformers>=5.4.0; python_version >= '3.10'",
"cryptography>=46.0.7",
"pypdf>=6.10.2,<7",
"pypdf>=6.13.3,<7",
"uv>=0.11.15,<1",
"python-multipart>=0.0.27,<1",
"gitpython>=3.1.50,<4",

45
uv.lock generated
View File

@@ -16,6 +16,9 @@ resolution-markers = [
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P3D"
[options.exclude-newer-package]
pypdf = "2026-06-18T00:00:00Z"
[manifest]
members = [
"crewai",
@@ -40,7 +43,7 @@ overrides = [
{ name = "pillow", specifier = ">=12.1.1" },
{ name = "pip", specifier = ">=26.1.2" },
{ name = "pydantic-settings", specifier = ">=2.14.0" },
{ name = "pypdf", specifier = ">=6.10.2,<7" },
{ name = "pypdf", specifier = ">=6.13.3,<7" },
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "starlette", specifier = ">=1.3.1" },
@@ -114,7 +117,7 @@ wheels = [
[[package]]
name = "aiobotocore"
version = "3.4.0"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -126,9 +129,9 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/50/a48ed11b15f926ce3dbb33e7fb0f25af17dbb99bcb7ae3b30c763723eca7/aiobotocore-3.4.0.tar.gz", hash = "sha256:a918b5cb903f81feba7e26835aed4b5e6bb2d0149d7f42bb2dd7d8089e3d9000", size = 122360, upload-time = "2026-04-07T06:12:24.884Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/89/9533b377e9412013cc43a539d81bc5f8feeb4b6830643821ad612f78b09b/aiobotocore-3.5.0.tar.gz", hash = "sha256:d45d1c4659ad0e48b694a5aa4ff18829100386f7de96c8d146ec7757a6f12918", size = 123061, upload-time = "2026-04-21T07:25:26.993Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/d8/ce9386e6d76ea79e61dee15e62aa48cff6be69e89246b0ac4a11857cb02c/aiobotocore-3.4.0-py3-none-any.whl", hash = "sha256:26290eb6830ea92d8a6f5f90b56e9f5cedd6d126074d5db63b195e281d982465", size = 88018, upload-time = "2026-04-07T06:12:22.684Z" },
{ url = "https://files.pythonhosted.org/packages/2d/05/6eeeadef45c24630af0ceae4d038b883e9a394786300529286ba8cc1e62d/aiobotocore-3.5.0-py3-none-any.whl", hash = "sha256:49ce35bb8b96b85d3251c2cbbb2ed7a028dc0cb0d0d0801f9ccca1ccd0d41ded", size = 88281, upload-time = "2026-04-21T07:25:25.258Z" },
]
[[package]]
@@ -654,7 +657,7 @@ wheels = [
[[package]]
name = "bedrock-agentcore"
version = "1.6.0"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
@@ -666,9 +669,9 @@ dependencies = [
{ name = "uvicorn" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/f6/2884c954343e794e3419348f5ffb0276a26f57b30af11f9fe63c4ca535c0/bedrock_agentcore-1.6.0.tar.gz", hash = "sha256:7ea176c3226dc6af8c399a8f9abb58629948cd8ed8675ece9f2f32b94e861b92", size = 512010, upload-time = "2026-03-31T23:10:06.561Z" }
sdist = { url = "https://files.pythonhosted.org/packages/01/65/69a66d812c5f86b902234fe91146004efcea907444a60f024f9afe13d150/bedrock_agentcore-1.7.0.tar.gz", hash = "sha256:cf632892f6bd055ce047eb91fe4d72f86569234faf3eb5cd1b2b614261a77d7f", size = 540824, upload-time = "2026-04-28T19:29:02.749Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/f8/bcf158979324f4f4d171588afffadb2154fa8499701290bfc7bdaf82bd3a/bedrock_agentcore-1.6.0-py3-none-any.whl", hash = "sha256:a4cd02f2bfb80fcc7a8c8835be8d55c778339f8286b071ac3aae579460dd2eb2", size = 164034, upload-time = "2026-03-31T23:10:04.902Z" },
{ url = "https://files.pythonhosted.org/packages/ba/9d/5f590afd5351e206d9a02f96777a69d1fc3edecfaa39bbba310248f21ea9/bedrock_agentcore-1.7.0-py3-none-any.whl", hash = "sha256:ee49695e613973baf01b4be400d3bc4b20ddedf3638765fb3bc6931a87fa0cd9", size = 178978, upload-time = "2026-04-28T19:29:00.944Z" },
]
[[package]]
@@ -682,16 +685,16 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.84"
version = "1.42.91"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/89/2d647bd717da55a8cc68602b197f53a5fa36fb95a2f9e76c4aff11a9cfd1/boto3-1.42.84.tar.gz", hash = "sha256:6a84b3293a5d8b3adf827a54588e7dcffcf0a85410d7dadca615544f97d27579", size = 112816, upload-time = "2026-04-06T19:39:07.585Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/31/cdf4326841613d1d181a77b3038a988800fb3373ca50de1639fba9fa87de/boto3-1.42.84-py3-none-any.whl", hash = "sha256:4d03ad3211832484037337292586f71f48707141288d9ac23049c04204f4ab03", size = 140555, upload-time = "2026-04-06T19:39:06.009Z" },
{ url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" },
]
[[package]]
@@ -715,16 +718,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
version = "1.42.84"
version = "1.42.91"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b4/b7/1c03423843fb0d1795b686511c00ee63fed1234c2400f469aeedfd42212f/botocore-1.42.84.tar.gz", hash = "sha256:234064604c80d9272a5e9f6b3566d260bcaa053a5e05246db90d7eca1c2cf44b", size = 15148615, upload-time = "2026-04-06T19:38:56.673Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/37/0c0c90361c8a1b9e6c75222ca24ae12996a298c0e18822a72ab229c37207/botocore-1.42.84-py3-none-any.whl", hash = "sha256:15f3fe07dfa6545e46a60c4b049fe2bdf63803c595ae4a4eec90e8f8172764f3", size = 14827061, upload-time = "2026-04-06T19:38:53.613Z" },
{ url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" },
]
[[package]]
@@ -1410,7 +1413,7 @@ watson = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'a2a'", specifier = "~=0.3.10" },
{ name = "aiobotocore", marker = "extra == 'aws'", specifier = "~=3.4.0" },
{ name = "aiobotocore", marker = "extra == 'aws'", specifier = "~=3.5.0" },
{ name = "aiocache", extras = ["memcached", "redis"], marker = "extra == 'a2a'", specifier = "~=0.12.3" },
{ name = "aiofiles", specifier = "~=24.1.0" },
{ name = "aiosqlite", specifier = "~=0.21.0" },
@@ -1418,8 +1421,8 @@ requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "azure-ai-inference", marker = "extra == 'azure-ai-inference'", specifier = "~=1.0.0b9" },
{ name = "azure-identity", marker = "extra == 'azure-ai-inference'", specifier = ">=1.17.0,<2" },
{ name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.79" },
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.79" },
{ name = "boto3", marker = "extra == 'aws'", specifier = "~=1.42.90" },
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "~=1.42.90" },
{ name = "cel-python", specifier = ">=0.5.0,<0.6" },
{ name = "chromadb", specifier = "~=1.1.0" },
{ name = "click", specifier = ">=8.1.7,<9" },
@@ -1584,7 +1587,7 @@ requires-dist = [
{ name = "aiofiles", specifier = "~=24.1.0" },
{ name = "av", specifier = "~=13.0.0" },
{ name = "pillow", specifier = "~=12.1.1" },
{ name = "pypdf", specifier = "~=6.10.0" },
{ name = "pypdf", specifier = "~=6.13.3" },
{ name = "python-magic", specifier = ">=0.4.27" },
{ name = "tinytag", specifier = "~=2.2.1" },
]
@@ -1731,7 +1734,7 @@ requires-dist = [
{ name = "beautifulsoup4", specifier = "~=4.13.4" },
{ name = "beautifulsoup4", marker = "extra == 'beautifulsoup4'", specifier = ">=4.12.3" },
{ name = "beautifulsoup4", marker = "extra == 'bedrock'", specifier = ">=4.13.4" },
{ name = "bedrock-agentcore", marker = "extra == 'bedrock'", specifier = ">=0.1.0" },
{ name = "bedrock-agentcore", marker = "extra == 'bedrock'", specifier = ">=1.7.0,<1.8.0" },
{ name = "browserbase", marker = "extra == 'browserbase'", specifier = ">=1.0.5" },
{ name = "composio-core", marker = "extra == 'composio-core'", specifier = ">=0.6.11.post1" },
{ name = "contextual-client", marker = "extra == 'contextual'", specifier = ">=0.1.0" },
@@ -7188,14 +7191,14 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.13.1"
version = "6.13.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/d9/9d12fa0d9660d03320725ff686c961b645a4218940a82296e1272d9e1ff0/pypdf-6.13.1.tar.gz", hash = "sha256:4841d8a4c1589e5833915dc0c7ddfacff80a2e0bcbeb5d1e681fecaa1674b03a", size = 6477811, upload-time = "2026-06-08T11:01:49.344Z" }
sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/dd/8f03e0a5788a5d1feb4550617c3e6db5e9099eaee248a3e482ddaeacbbb0/pypdf-6.13.1-py3-none-any.whl", hash = "sha256:e555e4ce3f561ef069307622f1374136ba964ca6ca24f24158701decaf83ed9b", size = 346259, upload-time = "2026-06-08T11:01:47.741Z" },
{ url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" },
]
[[package]]