mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-15 07:39:28 +00:00
Compare commits
25 Commits
1.15.18
...
iris/fix-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6e5d225 | ||
|
|
92eb5f9183 | ||
|
|
c90337ba5a | ||
|
|
3d72c707d5 | ||
|
|
98799a3b09 | ||
|
|
1cef70de52 | ||
|
|
b608a3595c | ||
|
|
f5db5a1788 | ||
|
|
968c3065d3 | ||
|
|
818f2624e8 | ||
|
|
8e46205619 | ||
|
|
1bc2e0722d | ||
|
|
48cc5d4e5e | ||
|
|
917b9df6d7 | ||
|
|
ec53d6f534 | ||
|
|
381fef73be | ||
|
|
bf56bb13bd | ||
|
|
614efcdd30 | ||
|
|
0e7625813b | ||
|
|
a35fbc864d | ||
|
|
265697b6f8 | ||
|
|
da4daadba0 | ||
|
|
e9d4c57b2a | ||
|
|
cba6c03646 | ||
|
|
1f6e327b3c |
3
.github/CONTRIBUTING.md
vendored
3
.github/CONTRIBUTING.md
vendored
@@ -103,7 +103,8 @@ chore(deps): bump pydantic to 2.11
|
||||
- Keep PRs focused — avoid bundling unrelated changes
|
||||
- PRs over 500 lines are labeled `size/XL` automatically
|
||||
- Title must follow the same conventional commit format
|
||||
- Link related issues where applicable
|
||||
- Link related issues where applicable (`#123`, `Fixes #123`, or the issue URL)
|
||||
- First-time contributors must open or pick an existing **open** issue first, then mention it in the PR title or body (for example `#123`). PRs without a linked open issue are closed automatically.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
23
.github/pull_request_template.md
vendored
Normal file
23
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
## Related issue
|
||||
|
||||
Fixes #
|
||||
|
||||
<!--
|
||||
First-time contributors must mention an existing open issue in this repo
|
||||
(for example #123). PRs without a linked open issue are closed automatically.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- Explain the solution and why. -->
|
||||
|
||||
## Verification
|
||||
|
||||
<!-- List the automated and manual checks used to verify the change. -->
|
||||
|
||||
- [ ] Tests added or updated for the changed behavior
|
||||
- [ ] Relevant tests and quality checks pass locally
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Include screenshots, compatibility notes, follow-up work, or "None". -->
|
||||
121
.github/workflows/ftc-require-issue.yml
vendored
Normal file
121
.github/workflows/ftc-require-issue.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: First-time contributor issue required
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: ftc-require-issue-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
require-issue:
|
||||
# Allow-list returning contributors. FIRST_TIMER / FIRST_TIME_CONTRIBUTOR
|
||||
# are often NONE on pull_request_target at opened time, which skipped the
|
||||
# previous deny-list and left first-timer PRs open.
|
||||
if: >
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
!contains(fromJSON('["MEMBER","OWNER","COLLABORATOR","CONTRIBUTOR"]'),
|
||||
github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require an open issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
run: |
|
||||
python3 << 'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
repo = os.environ["REPO"]
|
||||
pr_number = os.environ["PR_NUMBER"]
|
||||
owner, name = repo.split("/", 1)
|
||||
print(
|
||||
"author_association=",
|
||||
os.environ.get("AUTHOR_ASSOCIATION", ""),
|
||||
sep="",
|
||||
)
|
||||
|
||||
patterns = (
|
||||
re.compile(r"(?<![\w./-])#(\d+)\b"),
|
||||
re.compile(rf"{re.escape(owner)}/{re.escape(name)}#(\d+)\b"),
|
||||
re.compile(
|
||||
rf"https://github\.com/{re.escape(owner)}/{re.escape(name)}/issues/(\d+)\b"
|
||||
),
|
||||
)
|
||||
|
||||
def gh_json(*args: str) -> dict:
|
||||
return json.loads(
|
||||
subprocess.check_output(["gh", *args], text=True)
|
||||
)
|
||||
|
||||
def is_open_repo_issue(number: int) -> bool:
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/{repo}/issues/{number}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr or ""
|
||||
if "404" in stderr or "Not Found" in stderr:
|
||||
return False
|
||||
raise RuntimeError(
|
||||
f"GitHub API error looking up #{number}: {stderr}"
|
||||
)
|
||||
payload = json.loads(result.stdout)
|
||||
if "pull_request" in payload:
|
||||
return False
|
||||
return (payload.get("state") or "").lower() == "open"
|
||||
|
||||
pr = gh_json(
|
||||
"pr", "view", pr_number, "--repo", repo, "--json", "title,body,state"
|
||||
)
|
||||
text = f"{pr.get('title') or ''}\n{pr.get('body') or ''}"
|
||||
candidates = {
|
||||
int(match)
|
||||
for pattern in patterns
|
||||
for match in pattern.findall(text)
|
||||
}
|
||||
if any(is_open_repo_issue(number) for number in sorted(candidates)):
|
||||
sys.exit(0)
|
||||
|
||||
if (pr.get("state") or "").upper() == "CLOSED":
|
||||
sys.exit(0)
|
||||
|
||||
comment = f"""Thanks for the pull request.
|
||||
|
||||
First-time contributors need an associated open issue before we can review a PR.
|
||||
|
||||
1. Open an issue with a [template](https://github.com/{repo}/issues/new/choose), or pick an existing open one.
|
||||
2. Open a new PR (or reopen this one) whose title or body mentions that issue, for example `#123`.
|
||||
|
||||
See the [contributing guide](https://github.com/{repo}/blob/main/.github/CONTRIBUTING.md).
|
||||
"""
|
||||
subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"pr",
|
||||
"comment",
|
||||
pr_number,
|
||||
"--repo",
|
||||
repo,
|
||||
"--body",
|
||||
comment,
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["gh", "pr", "close", pr_number, "--repo", repo],
|
||||
check=True,
|
||||
)
|
||||
PY
|
||||
7
.github/workflows/vulnerability-scan.yml
vendored
7
.github/workflows/vulnerability-scan.yml
vendored
@@ -100,6 +100,13 @@ jobs:
|
||||
# GHSA-xph7-9rjv-w5fr (CVE-2026-45831): SimpleRBACAuthorizationProvider
|
||||
# ignores tenant/database/collection scope.
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr
|
||||
# nltk <=3.10.3: GHSA-8mgp-746c-j5xp (CVE-2026-81726): model-artifact
|
||||
# APIs bypass pathsec and read/write outside allowed roots. No patched
|
||||
# PyPI release yet (fixes are on nltk develop only). Transitive via
|
||||
# crewai-tools[xml] -> unstructured; CrewAI does not call those APIs.
|
||||
# TODO: drop this ignore when bumping nltk past 3.10.3 to a patched
|
||||
# release; keep the ignore list in sync with .pre-commit-config.yaml.
|
||||
--ignore-vuln GHSA-8mgp-746c-j5xp
|
||||
)
|
||||
uv run pip-audit "${pip_audit_args[@]}"
|
||||
continue-on-error: true
|
||||
|
||||
@@ -29,6 +29,7 @@ repos:
|
||||
- id: pip-audit
|
||||
name: pip-audit
|
||||
# Keep this ignore list in sync with .github/workflows/vulnerability-scan.yml.
|
||||
# TODO: drop --ignore-vuln GHSA-8mgp-746c-j5xp when bumping nltk past 3.10.3.
|
||||
entry: >-
|
||||
bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable
|
||||
--ignore-vuln PYSEC-2024-277
|
||||
@@ -59,7 +60,8 @@ repos:
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c
|
||||
--ignore-vuln GHSA-2wm9-hf6c-p5cr
|
||||
--ignore-vuln GHSA-36p7-vc44-83pf
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr' --
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr
|
||||
--ignore-vuln GHSA-8mgp-746c-j5xp' --
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push, manual]
|
||||
|
||||
@@ -13788,6 +13788,14 @@
|
||||
"edge/pt-BR/guides/flows/inputs-id-deprecation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Frontend",
|
||||
"icon": "browser",
|
||||
"pages": [
|
||||
"edge/pt-BR/guides/frontend/overview",
|
||||
"edge/pt-BR/guides/frontend/channels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Ferramentas",
|
||||
"icon": "wrench",
|
||||
@@ -26495,6 +26503,14 @@
|
||||
"edge/ko/guides/flows/inputs-id-deprecation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Frontend",
|
||||
"icon": "browser",
|
||||
"pages": [
|
||||
"edge/ko/guides/frontend/overview",
|
||||
"edge/ko/guides/frontend/channels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "도구",
|
||||
"icon": "wrench",
|
||||
@@ -39634,6 +39650,14 @@
|
||||
"edge/ar/guides/flows/inputs-id-deprecation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Frontend",
|
||||
"icon": "browser",
|
||||
"pages": [
|
||||
"edge/ar/guides/frontend/overview",
|
||||
"edge/ar/guides/frontend/channels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "الأدوات",
|
||||
"icon": "wrench",
|
||||
|
||||
@@ -736,7 +736,7 @@ memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Use Google Gemini
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
memory = Memory(llm="gemini/gemini-3.7-flash")
|
||||
|
||||
# Pass a pre-configured LLM instance with custom settings
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
|
||||
@@ -26,7 +26,7 @@ mode: "wide"
|
||||
- **معالجة الأخطاء** – توجيه كيفية استجابة الـ Agents للإخفاقات والاستثناءات وحالات انتهاء المهلة.
|
||||
- **مطالبات خاصة بالأدوات** – تعريف تعليمات مفصلة لكيفية استدعاء الأدوات أو استخدامها.
|
||||
|
||||
اطلع على [قوالب المطالبات الأصلية في مستودع CrewAI](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
|
||||
اطلع على [قوالب المطالبات الأصلية في مستودع CrewAI](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
|
||||
|
||||
## فهم تعليمات النظام الافتراضية
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ research_crew/
|
||||
}
|
||||
```
|
||||
|
||||
استبدل `provider/model-id` بالنموذج الذي تستخدمه، مثل `openai/gpt-4o` أو `anthropic/claude-sonnet-4-6` أو `gemini/gemini-2.0-flash-001`.
|
||||
استبدل `provider/model-id` بالنموذج الذي تستخدمه، مثل `openai/gpt-4o` أو `anthropic/claude-sonnet-4-6` أو `gemini/gemini-3.7-flash`.
|
||||
|
||||
## الخطوة 3: تعريف المهام وإعدادات الـ Crew
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ crewai flow add-crew content-crew
|
||||
}
|
||||
```
|
||||
|
||||
استبدل `provider/model-id` بالنموذج الذي تستخدمه، مثل `openai/gpt-4o` أو `gemini/gemini-2.0-flash-001` أو `anthropic/claude-sonnet-4-6`.
|
||||
استبدل `provider/model-id` بالنموذج الذي تستخدمه، مثل `openai/gpt-4o` أو `gemini/gemini-3.7-flash` أو `anthropic/claude-sonnet-4-6`.
|
||||
|
||||
3. أنشئ `src/guide_creator_flow/crews/content_crew/crew.jsonc`:
|
||||
|
||||
|
||||
156
docs/edge/ar/guides/frontend/channels.mdx
Normal file
156
docs/edge/ar/guides/frontend/channels.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: القنوات
|
||||
description: شغّل نفس وكيل CrewAI كروبوت على Slack أو Teams باستخدام CopilotKit Channels SDK ومنصة Intelligence المُدارة.
|
||||
icon: messages
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## قابل مستخدميك حيث هم بالفعل
|
||||
|
||||
وكيل CrewAI الذي بنيته في [النظرة العامة](/edge/ar/guides/frontend/overview) لا يجب أن يعيش خلف تطبيق ويب فقط. يمكن لنفس الـ Crew أو الـ Flow أن يعمل كروبوت داخل منصة مراسلة. لا حاجة لإعادة البناء ولا لنسخة ثانية من منطق وكيلك: يبقى الوكيل مكشوفًا عبر [بروتوكول AG-UI](https://docs.ag-ui.com)، وتقوم **قناة** بتشغيله من Slack أو Microsoft Teams.
|
||||
|
||||
يوفّر [Channels SDK](https://docs.copilotkit.ai/slack) من CopilotKit تلك القناة. تُعرّف `createChannel` في وقت تشغيل صغير، وتوجّهه إلى وكيل CrewAI الخاص بك، وتتولى منصة **Intelligence** المُدارة من CopilotKit التوسّط في الاتصال مع مزوّد المراسلة.
|
||||
|
||||
<Note>
|
||||
على خلاف بقية هذا القسم، فإن Channels **ليست ذاتية الاستضافة**. تعمل من خلال **CopilotKit Intelligence** — وهي سطح مطلوب لـ Channels، بحكم التصميم (تتوفر طبقة مجانية). تحتفظ Intelligence باتصال المنصة وبيانات الاعتماد، وتستقبل كل حدث من المنصة، وتسلّم الدور إلى عملية قناتك؛ تشغّل عمليتك الوكيل وتبثّ الرد مرة أخرى. تقوم بإعداد Slack مرة واحدة في لوحة تحكم Intelligence، ولا تدخل بيانات اعتماد المنصة عمليتك أبدًا. يبقى وكيلك وأدواتك وحالتك ملكًا لك.
|
||||
</Note>
|
||||
|
||||
## كيف تتكامل الأجزاء معًا
|
||||
|
||||
لا يتغير أي شيء بخصوص خادم وكيل CrewAI الخاص بك. يستمر في تقديم الـ Crew أو الـ Flow عبر AG-UI تمامًا كما في النظرة العامة. ما تضيفه هو عملية Node منفصلة طويلة الأمد مبنية باستخدام `@copilotkit/channels`: تسجّل قناة على `CopilotRuntime`، وتتصل بـ Intelligence، وتشغّل وكيلك كلما وصلت رسالة.
|
||||
|
||||
```
|
||||
Slack / Teams ──► CopilotKit Intelligence ──► channel process (Node) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
تحتفظ عملية القناة باتصال دائم مع بوابة Intelligence، لذا فهي تحتاج إلى مضيف طويل الأمد — لا يمكن لمعالج طلبات بلا خادم (serverless) أن يملك ذلك الاتصال. يمكن لخادم CrewAI الخاص بك أن يستمر في تقديم واجهة الويب الأمامية من النظرة العامة في الوقت نفسه: تطبيق الويب والقناة ما هما إلا عميلان لنقطة نهاية AG-UI واحدة.
|
||||
|
||||
## دليل التكامل
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="ثبّت حزم Channels">
|
||||
|
||||
يأتي Channels SDK مكتمل العناصر — كل منصة تُشحن في الحزمة الواحدة، بلا محوّل خاص بكل منصة لتثبيته. أضفه إلى جانب وقت التشغيل الذي يستضيف القناة وعميل CrewAI AG-UI:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/channels @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="أنشئ قناة في Intelligence">
|
||||
|
||||
في [لوحة تحكم CopilotKit](https://docs.copilotkit.ai/slack)، أنشئ قناة واربط Slack — ترشدك Intelligence خلال إنشاء تطبيق Slack وتحتفظ ببيانات اعتماده. يترك ذلك متغيّري بيئة لعمليتك، كلاهما من لوحة التحكم:
|
||||
|
||||
```bash
|
||||
export INTELLIGENCE_API_KEY=... # authenticates the runtime with Intelligence (free tier available)
|
||||
export INTELLIGENCE_CHANNEL_ID=... # the Channel ID, matched by createChannel({ name })
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="عرّف القناة">
|
||||
|
||||
تُعرّف `createChannel` القناة وتربط وكيلك بها. ابنِ الوكيل كمصنع لكل خيط (thread) بحيث تحصل كل محادثة على جلستها الخاصة، مستخدمًا نفس `CrewAIAgent` الذي تستخدمه النظرة العامة في وقت تشغيل الويب، موجّهًا إلى نقطة نهاية AG-UI الخاصة بك. تتيح `identifyUser: "platform"` لـ Intelligence ربط كل مستخدم من المنصة بهوية ثابتة.
|
||||
|
||||
```ts
|
||||
// channel.ts
|
||||
import { createChannel } from "@copilotkit/channels";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const channel = createChannel({
|
||||
name: process.env.INTELLIGENCE_CHANNEL_ID!, // must match the Channel ID in Intelligence
|
||||
identifyUser: "platform",
|
||||
// A fresh agent per conversation, pointed at your CrewAI AG-UI endpoint.
|
||||
agent: (threadId) => {
|
||||
const agent = new CrewAIAgent({ url: "http://localhost:8000/recipe" });
|
||||
agent.threadId = threadId;
|
||||
return agent;
|
||||
},
|
||||
});
|
||||
|
||||
// A mention subscribes the thread and runs the agent; afterwards every message
|
||||
// in a subscribed thread runs it without needing another mention.
|
||||
channel.onMention(async ({ thread }) => {
|
||||
await thread.subscribe();
|
||||
await thread.runAgent();
|
||||
});
|
||||
|
||||
channel.onMessage(async ({ thread }) => {
|
||||
if (await thread.isSubscribed()) await thread.runAgent();
|
||||
});
|
||||
|
||||
export { channel };
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="سجّل القناة على وقت التشغيل">
|
||||
|
||||
أنشئ `CopilotRuntime` مع بوابة Intelligence وقناتك، ثم قدّمه باستخدام `createCopilotNodeListener`. تبقى خريطة `agents` فارغة — القناة توفّر وكيلها الخاص. انتظر حتى تكون القناة جاهزة كي يفشل بدء التشغيل بصوت عالٍ عند وجود إعداد معطوب.
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { createServer } from "node:http";
|
||||
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
|
||||
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
|
||||
import { channel } from "./channel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {}, // the channel supplies its own agent; no web-facing agents needed
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY!, // free tier available
|
||||
}),
|
||||
channels: [channel],
|
||||
});
|
||||
|
||||
const listener = createCopilotNodeListener({ runtime });
|
||||
await listener.channels?.ready({ timeoutMs: 15_000 });
|
||||
|
||||
createServer(listener).listen(3123, () => {
|
||||
console.log("Channels runtime listening on port 3123");
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="شغّل وقت تشغيل القناة">
|
||||
|
||||
ابدأه إلى جانب خادم وكيل CrewAI الخاص بك:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1 — CrewAI agent server
|
||||
npx tsx server.ts # terminal 2 — Channels runtime
|
||||
```
|
||||
|
||||
اذكر الروبوت في Slack أو Teams فيشغّل الـ Crew أو الـ Flow الخاص بك، ويبثّ الرد مرة أخرى داخل الخيط. يبقى الخيط مشتركًا، لذا تعمل رسائل المتابعة دون الحاجة إلى ذكر آخر.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## نموذج الأحداث
|
||||
|
||||
تتفاعل القناة مع أحداث المنصة عبر معالِجات، ويستقبل كل معالِج خيطًا (`thread`) تديره بعدد قليل من الدوال:
|
||||
|
||||
- **`channel.onMention`** يُطلَق عندما يذكر مستخدم الروبوت بـ @. استدعِ `thread.subscribe()` للانضمام إلى الخيط، ثم `thread.runAgent()` لتشغيل وكيل CrewAI الخاص بك عند الذكر.
|
||||
- **`channel.onMessage`** يُطلَق عند كل رسالة في خيط يمكن للروبوت رؤيته. قيّده بـ `thread.isSubscribed()` كي لا يستجيب الوكيل إلا حيث انضمّ، ثم `thread.runAgent()`.
|
||||
- **`thread.runAgent()`** يشغّل وكيل CrewAI المرفق للدور الحالي ويبثّ مخرجاته مرة أخرى داخل القناة. مرّر `{ prompt }` لتجاوز النص الذي يعمل عليه الوكيل.
|
||||
|
||||
يستقبل وكيلك `RunAgentInput` عاديًا من AG-UI ويصدر أحداث AG-UI عادية؛ تبقى آليات المنصة خلف القناة، لذا يعمل نفس الـ Crew أو الـ Flow دون تغيير عبر كل منصة. تكشف القناة أيضًا معالِجات للترحيبات والمقاطعات والأوامر والتفاعلات والنوافذ (modals) — راجع [مرجع `Channel`](https://docs.copilotkit.ai/reference/channels/classes/Channel) للاطلاع على السطح الكامل.
|
||||
|
||||
## دعم المنصات
|
||||
|
||||
يغطي مسار Intelligence المُدار **Slack** و**Microsoft Teams** اليوم — يعمل نفس كود القناة على أيٍّ منهما، وتفيد `message.platform` / `thread.platform` بالأصل الأصلي. تُبلَغ المنصات الأخرى (Discord وTelegram وWhatsApp) عبر **محوّلات مباشرة** يشغّلها المطوّر بدلًا من المسار المُدار — تملك عمليتك الخاصة بيانات اعتماد المنصة والنقل. راجع [توثيق CopilotKit Channels](https://docs.copilotkit.ai/slack) للاطلاع على قائمة المنصات الحالية والإعداد الخاص بكل منصة.
|
||||
|
||||
## ذات صلة
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="النظرة العامة على الواجهة الأمامية" icon="browser" href="/edge/ar/guides/frontend/overview">
|
||||
قدّم الـ Crew أو الـ Flow الخاص بك عبر AG-UI — الأساس الذي تُبنى عليه كل قناة.
|
||||
</Card>
|
||||
<Card title="التدخل البشري (Human-in-the-Loop)" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
أوقف الوكيل مؤقتًا لجمع موافقة المستخدم أو مدخلاته في منتصف التشغيل.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
238
docs/edge/ar/guides/frontend/overview.mdx
Normal file
238
docs/edge/ar/guides/frontend/overview.mdx
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: النظرة العامة على الواجهة الأمامية
|
||||
description: ابنِ واجهات مستخدم تفاعلية لوكلاء CrewAI الخاصين بك باستخدام CopilotKit وبروتوكول AG-UI.
|
||||
icon: browser
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## امنح وكلاءك واجهة مستخدم
|
||||
|
||||
يشغّل CrewAI وكلاءك. ويمنحهم [CopilotKit](https://copilotkit.ai) واجهة أمامية. معًا يتيحان لك بناء تطبيقات يحادث فيها المستخدمون Crew أو Flow، ويشاهدونه يعمل في الوقت الفعلي، ويوافقون على قراراته، ويرون مخرجاته معروضة كواجهة حيّة بدلًا من جدران من النص.
|
||||
|
||||
يتصل الاثنان عبر [بروتوكول AG-UI](https://docs.ag-ui.com). تكشف حزمة `ag-ui-crewai` أي Crew أو Flow كنقطة نهاية AG-UI. وتستهلك خطافات (hooks) ومكوّنات React من CopilotKit تلك النقطة. يفتح ذلك تجارب تتجاوز بكثير صندوق المحادثة:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="واجهة المستخدم التوليدية (Generative UI)" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
اعرض استدعاءات أدوات الوكيل وحالته كمكوّنات React خاصة بك.
|
||||
</Card>
|
||||
<Card title="التدخل البشري (Human-in-the-Loop)" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
أوقف الوكيل مؤقتًا لجمع موافقة المستخدم أو مدخلاته في منتصف التشغيل.
|
||||
</Card>
|
||||
<Card title="الحالة المشتركة (Shared State)" icon="arrows-rotate" href="/edge/en/guides/frontend/shared-state">
|
||||
أبقِ حالة الوكيل وواجهة تطبيقك متزامنتين في الاتجاهين.
|
||||
</Card>
|
||||
<Card title="القنوات (Channels)" icon="messages" href="/edge/ar/guides/frontend/channels">
|
||||
شغّل نفس الوكيل كروبوت على Slack أو Discord أو Teams.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
يجعل هذا الدليل Crew أو Flow يتحدث مع واجهة أمامية بـ Next.js من البداية إلى النهاية. تبني بقية القسم على التطبيق الذي تعدّه هنا.
|
||||
|
||||
## البنية
|
||||
|
||||
هناك ثلاثة أجزاء:
|
||||
|
||||
1. **خادم وكيل CrewAI** — عملية Python تقدّم الـ Crew أو الـ Flow الخاص بك عبر AG-UI (FastAPI + `ag-ui-crewai`).
|
||||
2. **وقت تشغيل CopilotKit** — مسار Next.js يسجّل وكيلك ويوكّل الطلبات إليه.
|
||||
3. **الواجهة الأمامية بـ React** — مزوّد `<CopilotKit>` إلى جانب مكوّنات المحادثة والواجهة التوليدية.
|
||||
|
||||
```
|
||||
React app ──► CopilotKit runtime (/api/copilotkit) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
<Note>
|
||||
يغطي هذا الدليل المسار **الذاتي الاستضافة**: تشغّل خادم وكيل CrewAI بنفسك باستخدام `ag-ui-crewai`، ويعمل محليًا دون أي خدمة مُدارة. يقدّم CopilotKit أيضًا مسارًا **مُدارًا** (CopilotKit Cloud / Enterprise Intelligence) بخيوط مستضافة وأداة فحص — راجع [دليل البدء السريع لـ CopilotKit مع CrewAI](https://docs.copilotkit.ai/crewai-crews/quickstart) إن أردت ذلك بدلًا منه. كود الواجهة الأمامية في هذا القسم هو نفسه في الحالتين؛ الاختلاف فقط في كيفية استضافة الوكيل وتسجيله.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
يعمل CrewAI خلف AG-UI بثلاثة أشكال: الـ **Flows** العادية (المستخدمة في هذه الأدلة)، و**[الـ Flows المحادثية (Conversational Flows)](/edge/en/guides/frontend/conversational-flows)** (أصلية، مدركة للجلسة، قائمة على الأدوار، بتكافؤ كامل في الميزات)، والـ **Crews** (محادثة أساسية). الواجهة الأمامية في هذا القسم متطابقة عبرها جميعًا — الاختلاف فقط في تأليف الخلفية وتسجيلها.
|
||||
</Note>
|
||||
|
||||
## دليل التكامل
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="قدّم وكيلك عبر AG-UI">
|
||||
|
||||
ثبّت حزمة التكامل في مشروع CrewAI الخاص بك:
|
||||
|
||||
```bash
|
||||
pip install ag-ui-crewai
|
||||
```
|
||||
|
||||
اكشف وكيلك من تطبيق FastAPI. تستخدم الـ Flows دالة `add_crewai_flow_fastapi_endpoint`؛ وتستخدم الـ Crews دالة `add_crewai_crew_fastapi_endpoint`. يمكنك تسجيل ما تشاء منها، كلٌّ على مساره الخاص.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Flow
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
|
||||
from my_agents.recipe_flow import RecipeFlow
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_flow_fastapi_endpoint(
|
||||
app=app,
|
||||
flow=RecipeFlow(),
|
||||
path="/recipe",
|
||||
)
|
||||
```
|
||||
|
||||
```python Crew
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_crew_fastapi_endpoint
|
||||
from my_agents.research_crew import ResearchCrew
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_crew_fastapi_endpoint(
|
||||
app=app,
|
||||
crew=ResearchCrew().crew(),
|
||||
path="/research",
|
||||
)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
شغّله:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000
|
||||
```
|
||||
|
||||
<Note>
|
||||
اضبط متغيّرات البيئة الخاصة بمزوّد الـ LLM الخاص بك (على سبيل المثال `OPENAI_API_KEY`) قبل بدء الخادم.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="أنشئ تطبيق Next.js">
|
||||
|
||||
إن لم تكن لديك واجهة أمامية بعد، أنشئ هيكلًا:
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest my-app
|
||||
cd my-app
|
||||
```
|
||||
|
||||
ثبّت CopilotKit وعميل CrewAI AG-UI:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="أضف وقت تشغيل CopilotKit">
|
||||
|
||||
أنشئ مسارًا يسجّل وكيل (أو وكلاء) CrewAI مع وقت تشغيل CopilotKit. يشير كل وكيل إلى مسار على خادم Python الخاص بك عبر `CrewAIAgent`.
|
||||
|
||||
```ts
|
||||
// app/api/copilotkit/route.ts
|
||||
import {
|
||||
CopilotRuntime,
|
||||
InMemoryAgentRunner,
|
||||
createCopilotEndpoint,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
recipe: new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
},
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
const handler = handle(app);
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="غلّف تطبيقك بالمزوّد">
|
||||
|
||||
وجّه `<CopilotKit>` إلى مسار وقت التشغيل واذكر اسم الوكيل الذي سجّلته.
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="recipe">
|
||||
<YourApp />
|
||||
<CopilotSidebar agentId="recipe" labels={{ modalHeaderTitle: "Assistant" }} />
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="شغّله">
|
||||
|
||||
ابدأ العمليتين وافتح التطبيق. تشغّل المحادثة في الشريط الجانبي الآن الـ Crew أو الـ Flow الخاص بك.
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1
|
||||
npm run dev # terminal 2
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## خيارات واجهة المحادثة
|
||||
|
||||
يشحن CopilotKit ثلاثة أسطح محادثة قابلة للتبديل. بدّل المكوّن؛ يبقى التوصيل متطابقًا.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx Sidebar
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotSidebar agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Popup
|
||||
import { CopilotPopup } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotPopup agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Inline
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotChat agentId="recipe" />
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## إلى أين تذهب بعد ذلك
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="واجهة المستخدم التوليدية (Generative UI)" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
اعرض استدعاءات الأدوات وحالة الوكيل كمكوّنات مخصّصة.
|
||||
</Card>
|
||||
<Card title="إجراءات الواجهة الأمامية (Frontend Actions)" icon="bolt" href="/edge/en/guides/frontend/frontend-actions">
|
||||
دع الوكيل يستدعي دوالًا تعمل في المتصفح.
|
||||
</Card>
|
||||
<Card title="التدخل البشري (Human-in-the-Loop)" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
قيّد إجراءات الوكيل خلف موافقة المستخدم.
|
||||
</Card>
|
||||
<Card title="الحالة التنبؤية (Predictive State)" icon="gauge-high" href="/edge/en/guides/frontend/predictive-state-updates">
|
||||
ابثّ الحالة قيد التنفيذ إلى الواجهة أثناء عمل الوكيل.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -176,7 +176,7 @@ grep -r "llm:" --include="*.yaml" .
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(model="gemini/gemini-2.0-flash")
|
||||
llm = LLM(model="gemini/gemini-3.7-flash")
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -312,7 +312,7 @@ llm = LLM(model="anthropic/claude-haiku-3-5") # Fast & affordable
|
||||
# Together AI → OpenAI or Gemini
|
||||
# llm = LLM(model="together_ai/meta-llama/Meta-Llama-3.1-70B")
|
||||
llm = LLM(model="openai/gpt-4o") # High quality
|
||||
llm = LLM(model="gemini/gemini-2.0-flash") # Fast & capable
|
||||
llm = LLM(model="gemini/gemini-3.7-flash") # Fast & capable
|
||||
|
||||
# Mistral → Anthropic or OpenAI
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
|
||||
@@ -141,7 +141,7 @@ mode: "wide"
|
||||
# Example using Gemini's OpenAI-compatible API.
|
||||
os.environ["OPENAI_API_KEY"] = "your-gemini-key" # Should start with AIza...
|
||||
os.environ["OPENAI_API_BASE"] = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-2.0-flash" # Add your Gemini model here, under openai/
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-3.7-flash" # Add your Gemini model here, under openai/
|
||||
```
|
||||
</CodeGroup>
|
||||
</Tab>
|
||||
@@ -159,7 +159,7 @@ mode: "wide"
|
||||
```python Google
|
||||
# Example using Gemini's OpenAI-compatible API
|
||||
llm = LLM(
|
||||
model="openai/gemini-2.0-flash",
|
||||
model="openai/gemini-3.7-flash",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
api_key="your-gemini-key", # Should start with AIza...
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ Planning agents benefit from reasoning models that can handle complex strategic
|
||||
from crewai import Agent, Task, Crew, LLM
|
||||
|
||||
# High-capability reasoning model for strategic planning
|
||||
manager_llm = LLM(model="gemini-2.5-flash-preview-05-20", temperature=0.1)
|
||||
manager_llm = LLM(model="gemini/gemini-3.7-flash", temperature=0.1)
|
||||
|
||||
# Creative model for content generation
|
||||
content_llm = LLM(model="claude-3-5-sonnet-20241022", temperature=0.7)
|
||||
@@ -409,7 +409,7 @@ Rather than repeating the strategic framework, here's a tactical checklist for i
|
||||
# Manager or coordination agents
|
||||
manager_agent = Agent(
|
||||
role="Project Manager",
|
||||
llm=LLM(model="gemini-2.5-flash-preview-05-20"), # Premium for coordination
|
||||
llm=LLM(model="gemini/gemini-3.7-flash"), # Premium for coordination
|
||||
# ... rest of config
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ mode: "wide"
|
||||
عند تفعيل ميزة `share_crew`، يتم جمع بيانات تفصيلية تشمل أوصاف المهام وخلفيات وأهداف الوكلاء وسمات محددة أخرى
|
||||
لتوفير رؤى أعمق. قد يتضمن جمع البيانات الموسع هذا معلومات شخصية إذا دمجها المستخدمون في طواقمهم أو مهامهم.
|
||||
يجب على المستخدمين النظر بعناية في محتوى طواقمهم ومهامهم قبل تفعيل `share_crew`.
|
||||
يمكن للمستخدمين تعطيل القياس عن بُعد عبر تعيين متغير البيئة `CREWAI_DISABLE_TELEMETRY` إلى `true` أو تعيين `OTEL_SDK_DISABLED` إلى `true` (لاحظ أن الأخير يعطل جميع أدوات OpenTelemetry عالمياً).
|
||||
يمكن للمستخدمين تعطيل القياس عن بُعد في CrewAI عبر تعيين `CREWAI_DISABLE_TELEMETRY` إلى `true` أو `1` أو `yes` أو `on` (بغض النظر عن حالة الأحرف). `OTEL_SDK_DISABLED` بنفس القيم يعطّل أيضاً مُصدِّر CrewAI. مجموعة أدوات OpenTelemetry نفسها ما تزال تقبل `true` فقط لتعطيل بقية أدوات القياس في العملية.
|
||||
|
||||
### أمثلة:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (أو `yes` / `on`) يعمل بنفس طريقة `true`. تُتجاهل القيم غير المعروفة ويبقى القياس عن بُعد مفعّلاً.
|
||||
|
||||
### العزل عن إعداد OpenTelemetry الخاص بك
|
||||
|
||||
يعمل القياس عن بُعد الخاص بـ CrewAI على `TracerProvider` خاص به ولا يسجل نفسه
|
||||
@@ -61,7 +63,7 @@ os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
| نعم | سمات LLM | تشمل: الاسم، model_name، model، top_k، temperature، واسم فئة LLM. كلها بيانات تقنية غير شخصية. |
|
||||
| نعم | إنشاء مشروع باستخدام CLI الخاص بـ CrewAI | تشمل: أن مشروعًا جديدًا أُنشئ عبر `crewai create`، ونوعه (`crew` أو `json_crew` أو `flow`)، ومعرّف المشروع الذي تم توليده لهذا المشروع الجديد وكُتب في ملف `pyproject.toml` الخاص به. وهو معرّف المشروع الجديد نفسه، ويُسجَّل بشكل منفصل عن `project_id` الخاص بالمجلد الذي شُغّل منه الأمر — وقد يختلفان. لا اسم مشروع، ولا محتويات ملفات، ولا شيفرة. لا بيانات شخصية. |
|
||||
| نعم | محاولة نشر الطاقم باستخدام CLI الخاص بـ CrewAI | تشمل: حقيقة إجراء النشر ومعرّف الطاقم، وما إذا كان يحاول سحب السجلات، وما إذا بدأ النشر من أمر CLI أو من واجهة التشغيل TUI. لا تُسجَّل محتويات المشروع أو الطاقم. لا توجد بيانات شخصية. |
|
||||
| نعم | بيئة التنفيذ | تشمل: مساعد البرمجة بالذكاء الاصطناعي الذي يشغّل العملية إن وُجد (واحد من قائمة ثابتة مثل `claude_code` أو `codex` أو `cursor` أو `unknown`)، ومكان تشغيل العملية (واحد من قائمة ثابتة مثل `ci` أو `container` أو `serverless` أو `interactive`)، و`project_id` من ملف `pyproject.toml` عند ضبطه. يتحقق الاكتشاف فقط مما إذا كانت متغيرات البيئة المعروفة مضبوطة، ولا يقرأ قيمها أبدًا. لا بيانات شخصية. |
|
||||
| نعم | بيئة التنفيذ | تشمل: مساعد البرمجة بالذكاء الاصطناعي الذي يشغّل العملية إن وُجد (واحد من قائمة ثابتة مثل `claude_code` أو `codex` أو `cursor` أو `unknown`)، ومكان تشغيل العملية (واحد من قائمة ثابتة مثل `ci` أو `container` أو `serverless` أو `interactive`)، و`project_id` من ملف `pyproject.toml` عند ضبطه، ونطاقًا تقريبيًا لحجم الجهاز (واحد من `1-2` أو `3-4` أو `5-8` أو `9-16` أو `17-32` أو `33+` أو `unknown`). النطاق مجال وليس العدد الدقيق للأنوية أبدًا — العدد الدقيق اختياري فقط، ضمن «معلومات البيئة» أدناه. تأتي فئة الحجم من عدد أنوية المضيف؛ ويتحقق اكتشاف المساعد وموقع التشغيل فقط مما إذا كانت متغيرات البيئة المعروفة مضبوطة، ولا يقرأ قيمها أبدًا. لا بيانات شخصية. |
|
||||
| نعم | إشارات دورة حياة التدفق | تشمل: بدء التدفق، وما إذا اكتمل أو فشل، وما إذا فشلت إحدى طرقه، وما إذا توقف مؤقتًا لانتظار إدخال أو ملاحظات بشرية، وما إذا كان البدء تشغيلًا مستأنفًا، وما إذا فشل دور محادثة، ومدة تشغيل التدفق، وما إذا كان التدفق مما تشغّله CrewAI داخليًا أو مما كتبته أنت. ويُسجَّل اسم التدفق، كما هو الحال بالفعل لإنشاء التدفق وتنفيذه. وعند فشل تدفق أو إحدى طرقه، يُسجَّل **اسم فئة** الاستثناء (مثل `TimeoutError`) لتشخيص الأعطال — ولا تُسجَّل أبدًا رسالة الخطأ، التي قد تحتوي على مطالبات أو مخرجات النموذج أو مسارات ملفات أو بيانات اعتماد. ولا تُسجَّل أبدًا أسماء الطرق أو حالة التدفق. لا توجد بيانات شخصية. |
|
||||
| نعم | إشارة مشاركة التتبع | تشمل: نجاح مشاركة دفعة من عمليات التتبع مع CrewAI AMP، وما إذا تمت المشاركة بشكل مجهول (قبل إنشاء حساب) أو مرتبطة بحسابك. ومثل كل span، تحمل أيضًا سمات بيئة التنفيذ الموضحة أعلاه (`project_id` عند تكوينه، ومساعد البرمجة، وبيئة التشغيل). يصف هذا الصف بيانات القياس عن بُعد الخاصة بالمشاركة فقط — وليس محتويات التتبع أو الوصول الذي تمنحه روابط التتبع المشتركة. لا تُسجَّل محتويات التتبع أو المدخلات أو المخرجات في هذه الإشارة. قبل مشاركة التتبعات، راجع الأسرار والبيانات الشخصية وإعدادات التنقيح والاحتفاظ في AMP. |
|
||||
| لا | بيانات الوكيل الموسّعة | تشمل: وصف الهدف، نص الخلفية، معرّف ملف موجهات i18n. يجب على المستخدمين التأكد من عدم تضمين معلومات شخصية في حقول النص. |
|
||||
|
||||
@@ -50,16 +50,15 @@ mode: "wide"
|
||||
- **سلامة الذكاء الاصطناعي**: تنفيذ فحوصات الإشراف على المحتوى والسلامة
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
|
||||
@@ -740,7 +740,7 @@ memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Use Google Gemini
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
memory = Memory(llm="gemini/gemini-3.7-flash")
|
||||
|
||||
# Pass a pre-configured LLM instance with custom settings
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
|
||||
@@ -26,7 +26,7 @@ Under the hood, CrewAI employs a modular prompt system that you can customize ex
|
||||
- **Error handling** – Direct how agents respond to failures, exceptions, or timeouts.
|
||||
- **Tool-specific prompts** – Define detailed instructions for how tools are invoked or utilized.
|
||||
|
||||
Check out the [original prompt templates in CrewAI's repository](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) to see how these elements are organized. From there, you can override or adapt them as needed to unlock advanced behaviors.
|
||||
Check out the [original prompt templates in CrewAI's repository](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) to see how these elements are organized. From there, you can override or adapt them as needed to unlock advanced behaviors.
|
||||
|
||||
## Understanding Default System Instructions
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Replace the generated `agents/researcher.jsonc` file and add `agents/analyst.jso
|
||||
}
|
||||
```
|
||||
|
||||
Replace `provider/model-id` with the model you use, for example `openai/gpt-4o`, `anthropic/claude-sonnet-4-6`, or `gemini/gemini-2.0-flash-001`.
|
||||
Replace `provider/model-id` with the model you use, for example `openai/gpt-4o`, `anthropic/claude-sonnet-4-6`, or `gemini/gemini-3.7-flash`.
|
||||
|
||||
## Step 3: Define Tasks and Crew Settings
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Now, let's configure the content writer crew with JSONC. We'll set up two specia
|
||||
}
|
||||
```
|
||||
|
||||
Replace `provider/model-id` with the model you use, for example `openai/gpt-4o`, `gemini/gemini-2.0-flash-001`, or `anthropic/claude-sonnet-4-6`.
|
||||
Replace `provider/model-id` with the model you use, for example `openai/gpt-4o`, `gemini/gemini-3.7-flash`, or `anthropic/claude-sonnet-4-6`.
|
||||
|
||||
3. Create `src/guide_creator_flow/crews/content_crew/crew.jsonc`:
|
||||
|
||||
@@ -483,7 +483,7 @@ Flows allow you to make direct calls to language models when you need simple, st
|
||||
|
||||
```python
|
||||
llm = LLM(
|
||||
model="model-id-here", # gpt-4o, gemini-2.0-flash, anthropic/claude...
|
||||
model="model-id-here", # gpt-4o, gemini/gemini-3.7-flash, anthropic/claude...
|
||||
response_format=GuideOutline
|
||||
)
|
||||
response = llm.call(messages=messages)
|
||||
|
||||
@@ -1,117 +1,148 @@
|
||||
---
|
||||
title: Channels
|
||||
description: Run the same CrewAI agent as a chat bot on Slack and Discord with the CopilotKit Channels SDK.
|
||||
icon: slack
|
||||
description: Run the same CrewAI agent as a Slack or Teams bot with the CopilotKit Channels SDK and managed Intelligence platform.
|
||||
icon: messages
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Meet your users where they already are
|
||||
|
||||
The CrewAI agent you built in the [Overview](/edge/en/guides/frontend/overview) does not have to live behind a web app. The same Crew or Flow can run as a bot inside a messaging platform. No rebuild, no second copy of your agent logic: the agent stays exposed over the [AG-UI protocol](https://docs.ag-ui.com), and a bot process drives it.
|
||||
The CrewAI agent you built in the [Overview](/edge/en/guides/frontend/overview) does not have to live behind a web app. The same Crew or Flow can run as a bot inside a messaging platform. No rebuild, no second copy of your agent logic: the agent stays exposed over the [AG-UI protocol](https://docs.ag-ui.com), and a **channel** drives it from Slack or Microsoft Teams.
|
||||
|
||||
CopilotKit's [Channels SDK](https://docs.copilotkit.ai/reference/channels) provides that bot process. It ships a platform-agnostic engine plus per-platform adapters.
|
||||
CopilotKit's [Channels SDK](https://docs.copilotkit.ai/slack) provides that channel. You declare a `createChannel` in a small runtime, point it at your CrewAI agent, and CopilotKit's managed **Intelligence** platform brokers the connection to the messaging provider.
|
||||
|
||||
<Note>
|
||||
Unlike the rest of this section, Channels is **not self-hosted**. It runs through **CopilotKit Intelligence** — a required surface for Channels, by design (a free tier is available). Intelligence holds the platform connection and credentials, receives each platform event, and delivers the turn to your channel process; your process runs the agent and streams the reply back. You configure Slack once in the Intelligence dashboard, and platform credentials never enter your process. Your agent, tools, and state stay yours.
|
||||
</Note>
|
||||
|
||||
## How it fits together
|
||||
|
||||
Nothing about your agent server changes. It keeps serving your Crew or Flow over AG-UI exactly as in the Overview. What you add is a separate **bot process**: it connects to a platform adapter, listens for messages, and runs your agent when it is messaged. The reply streams back into the channel.
|
||||
Nothing about your CrewAI agent server changes. It keeps serving your Crew or Flow over AG-UI exactly as in the Overview. What you add is a separate long-running Node process built with `@copilotkit/channels`: it registers a channel on the `CopilotRuntime`, connects to Intelligence, and runs your agent whenever a message arrives.
|
||||
|
||||
```
|
||||
Slack / Discord ──► Channels bot process ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
Slack / Teams ──► CopilotKit Intelligence ──► channel process (Node) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
Your agent server can keep serving the web frontend from the Overview at the same time. The web app and the bot are just two clients of one AG-UI endpoint.
|
||||
The channel process holds a persistent connection to the Intelligence gateway, so it needs a long-running host — a serverless request handler cannot own that connection. Your CrewAI server can keep serving the web frontend from the Overview at the same time: the web app and the channel are just two clients of one AG-UI endpoint.
|
||||
|
||||
## Slack
|
||||
## Integration guide
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="Install the Channels packages">
|
||||
|
||||
The Channels SDK is batteries-included — every platform ships in the one package, with no per-platform adapter to install. Add it alongside the runtime that hosts the channel and the CrewAI AG-UI client:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/channels @copilotkit/channels-slack @ag-ui/crewai
|
||||
npm install @copilotkit/channels @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create a Slack app and get tokens">
|
||||
<Step title="Create a Channel in Intelligence">
|
||||
|
||||
Create an app in the Slack API dashboard for your workspace, enable Socket Mode, and grant it the message and event scopes it needs to read and post in channels. Then expose its tokens to the bot process:
|
||||
In the [CopilotKit dashboard](https://docs.copilotkit.ai/slack), create a Channel and connect Slack — Intelligence walks you through creating the Slack app and holds its credentials. That leaves two environment variables for your process, both from the dashboard:
|
||||
|
||||
```bash
|
||||
export SLACK_BOT_TOKEN=xoxb-... # bot user token
|
||||
export SLACK_APP_TOKEN=xapp-... # app-level token (Socket Mode)
|
||||
export INTELLIGENCE_API_KEY=... # authenticates the runtime with Intelligence (free tier available)
|
||||
export INTELLIGENCE_CHANNEL_ID=... # the Channel ID, matched by createChannel({ name })
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Point the bot at your CrewAI agent">
|
||||
<Step title="Define the channel">
|
||||
|
||||
`createBot` wires a Slack adapter to your agent. The `agent` factory returns a `CrewAIAgent` pointed at the AG-UI path your server exposes (the same URL you registered in the runtime in the Overview).
|
||||
`createChannel` declares the channel and attaches your agent. Build the agent as a per-thread factory so each conversation gets its own session, using the same `CrewAIAgent` the Overview uses in the web runtime, pointed at your AG-UI endpoint. `identifyUser: "platform"` lets Intelligence map each platform user to a stable identity.
|
||||
|
||||
```ts
|
||||
// bot.ts
|
||||
import { createBot } from "@copilotkit/channels";
|
||||
import { slack, defaultSlackTools, defaultSlackContext } from "@copilotkit/channels-slack";
|
||||
// channel.ts
|
||||
import { createChannel } from "@copilotkit/channels";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const bot = createBot({
|
||||
adapters: [
|
||||
slack({
|
||||
botToken: process.env.SLACK_BOT_TOKEN!, // xoxb-…
|
||||
appToken: process.env.SLACK_APP_TOKEN!, // xapp-… (Socket Mode)
|
||||
}),
|
||||
],
|
||||
agent: (threadId) => new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
tools: [...defaultSlackTools],
|
||||
context: [...defaultSlackContext],
|
||||
const channel = createChannel({
|
||||
name: process.env.INTELLIGENCE_CHANNEL_ID!, // must match the Channel ID in Intelligence
|
||||
identifyUser: "platform",
|
||||
// A fresh agent per conversation, pointed at your CrewAI AG-UI endpoint.
|
||||
agent: (threadId) => {
|
||||
const agent = new CrewAIAgent({ url: "http://localhost:8000/recipe" });
|
||||
agent.threadId = threadId;
|
||||
return agent;
|
||||
},
|
||||
});
|
||||
|
||||
bot.start();
|
||||
// A mention subscribes the thread and runs the agent; afterwards every message
|
||||
// in a subscribed thread runs it without needing another mention.
|
||||
channel.onMention(async ({ thread }) => {
|
||||
await thread.subscribe();
|
||||
await thread.runAgent();
|
||||
});
|
||||
|
||||
channel.onMessage(async ({ thread }) => {
|
||||
if (await thread.isSubscribed()) await thread.runAgent();
|
||||
});
|
||||
|
||||
export { channel };
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Run the bot">
|
||||
<Step title="Register the channel on the runtime">
|
||||
|
||||
Start the bot process alongside your agent server:
|
||||
Create a `CopilotRuntime` with the Intelligence gateway and your channel, then serve it with `createCopilotNodeListener`. The `agents` map stays empty — the channel supplies its own agent. Wait for the channel to be ready so a broken config fails startup loudly.
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { createServer } from "node:http";
|
||||
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
|
||||
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
|
||||
import { channel } from "./channel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {}, // the channel supplies its own agent; no web-facing agents needed
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY!, // free tier available
|
||||
}),
|
||||
channels: [channel],
|
||||
});
|
||||
|
||||
const listener = createCopilotNodeListener({ runtime });
|
||||
await listener.channels?.ready({ timeoutMs: 15_000 });
|
||||
|
||||
createServer(listener).listen(3123, () => {
|
||||
console.log("Channels runtime listening on port 3123");
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Run the channel runtime">
|
||||
|
||||
Start it alongside your CrewAI agent server:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1 — CrewAI agent server
|
||||
node bot.ts # terminal 2 — Slack bot
|
||||
npx tsx server.ts # terminal 2 — Channels runtime
|
||||
```
|
||||
|
||||
Message the bot in Slack and it runs your Crew or Flow, streaming the reply back into the thread.
|
||||
Mention the bot in Slack or Teams and it runs your Crew or Flow, streaming the reply back into the thread. The thread stays subscribed, so follow-up messages run without another mention.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
Slack app scopes, Socket Mode setup, and the full adapter options are maintained by CopilotKit. Follow the [Slack channel reference](https://docs.copilotkit.ai/reference/channels/slack) together with Slack's own app setup guide for the authoritative steps.
|
||||
</Note>
|
||||
## The event model
|
||||
|
||||
## Discord
|
||||
A channel reacts to platform events with handlers, and each handler receives a `thread` you drive with a few methods:
|
||||
|
||||
Discord uses the same `createBot` engine with the Discord adapter from `@copilotkit/channels-discord`:
|
||||
- **`channel.onMention`** fires when a user @-mentions the bot. Call `thread.subscribe()` to join the thread, then `thread.runAgent()` to run your CrewAI agent on the mention.
|
||||
- **`channel.onMessage`** fires on every message in a thread the bot can see. Gate it with `thread.isSubscribed()` so the agent only responds where it has joined, then `thread.runAgent()`.
|
||||
- **`thread.runAgent()`** runs the attached CrewAI agent for the current turn and streams its output back into the channel. Pass `{ prompt }` to override the text the agent runs on.
|
||||
|
||||
```ts
|
||||
import { createBot } from "@copilotkit/channels";
|
||||
import { discord } from "@copilotkit/channels-discord";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const bot = createBot({
|
||||
adapters: [discord({ token: process.env.DISCORD_BOT_TOKEN! })],
|
||||
agent: (threadId) => new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
});
|
||||
|
||||
bot.start();
|
||||
```
|
||||
|
||||
See the [Discord channel reference](https://docs.copilotkit.ai/reference/channels/discord) for the exact adapter options and bot setup.
|
||||
Your agent receives an ordinary AG-UI `RunAgentInput` and emits ordinary AG-UI events; the platform mechanics stay behind the channel, so the same Crew or Flow runs unchanged across every platform. The channel also exposes handlers for welcomes, interrupts, commands, reactions, and modals — see the [`Channel` reference](https://docs.copilotkit.ai/reference/channels/classes/Channel) for the full surface.
|
||||
|
||||
## Platform support
|
||||
|
||||
Slack and Discord have official Channels adapters (`@copilotkit/channels-slack`, `@copilotkit/channels-discord`). Microsoft Teams is available through CopilotKit's managed offering (currently waitlisted). Check the [Channels reference](https://docs.copilotkit.ai/reference/channels) for the current list before promising a platform.
|
||||
The managed Intelligence path covers **Slack** and **Microsoft Teams** today — the same channel code runs on either, and `message.platform` / `thread.platform` report the native origin. Other platforms (Discord, Telegram, WhatsApp) are reached through developer-operated **direct adapters** rather than the managed path — your own process holds the platform credentials and transport. Check the [CopilotKit Channels documentation](https://docs.copilotkit.ai/slack) for the current platform list and per-platform setup.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ The two connect through the [AG-UI protocol](https://docs.ag-ui.com). The `ag-ui
|
||||
<Card title="Shared State" icon="arrows-rotate" href="/edge/en/guides/frontend/shared-state">
|
||||
Keep agent state and your app UI in two-way sync.
|
||||
</Card>
|
||||
<Card title="Channels" icon="slack" href="/edge/en/guides/frontend/channels">
|
||||
<Card title="Channels" icon="messages" href="/edge/en/guides/frontend/channels">
|
||||
Run the same agent as a Slack, Discord, or Teams bot.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -176,7 +176,7 @@ grep -r "llm:" --include="*.yaml" .
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
|
||||
# After (Native):
|
||||
llm = LLM(model="gemini/gemini-2.0-flash")
|
||||
llm = LLM(model="gemini/gemini-3.7-flash")
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -399,7 +399,7 @@ llm = LLM(model="anthropic/claude-haiku-3-5") # Fast & affordable
|
||||
# Together AI → OpenAI or Gemini
|
||||
# llm = LLM(model="together_ai/meta-llama/Meta-Llama-3.1-70B")
|
||||
llm = LLM(model="openai/gpt-4o") # High quality
|
||||
llm = LLM(model="gemini/gemini-2.0-flash") # Fast & capable
|
||||
llm = LLM(model="gemini/gemini-3.7-flash") # Fast & capable
|
||||
|
||||
# Mistral → Anthropic or OpenAI
|
||||
# llm = LLM(model="mistral/mistral-large-latest")
|
||||
|
||||
@@ -141,7 +141,7 @@ You can connect to OpenAI-compatible LLMs using either environment variables or
|
||||
# Example using Gemini's OpenAI-compatible API.
|
||||
os.environ["OPENAI_API_KEY"] = "your-gemini-key" # Should start with AIza...
|
||||
os.environ["OPENAI_API_BASE"] = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-2.0-flash" # Add your Gemini model here, under openai/
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-3.7-flash" # Add your Gemini model here, under openai/
|
||||
```
|
||||
</CodeGroup>
|
||||
</Tab>
|
||||
@@ -159,7 +159,7 @@ You can connect to OpenAI-compatible LLMs using either environment variables or
|
||||
```python Google
|
||||
# Example using Gemini's OpenAI-compatible API
|
||||
llm = LLM(
|
||||
model="openai/gemini-2.0-flash",
|
||||
model="openai/gemini-3.7-flash",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
api_key="your-gemini-key", # Should start with AIza...
|
||||
)
|
||||
|
||||
@@ -147,7 +147,7 @@ Planning agents benefit from reasoning models that can handle complex strategic
|
||||
from crewai import Agent, Task, Crew, LLM
|
||||
|
||||
# High-capability reasoning model for strategic planning
|
||||
manager_llm = LLM(model="gemini-2.5-flash-preview-05-20", temperature=0.1)
|
||||
manager_llm = LLM(model="gemini/gemini-3.7-flash", temperature=0.1)
|
||||
|
||||
# Creative model for content generation
|
||||
content_llm = LLM(model="claude-3-5-sonnet-20241022", temperature=0.7)
|
||||
@@ -412,7 +412,7 @@ Rather than repeating the strategic framework, here's a tactical checklist for i
|
||||
# Manager or coordination agents
|
||||
manager_agent = Agent(
|
||||
role="Project Manager",
|
||||
llm=LLM(model="gemini-2.5-flash-preview-05-20"), # Premium for coordination
|
||||
llm=LLM(model="gemini/gemini-3.7-flash"), # Premium for coordination
|
||||
# ... rest of config
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ usage of tools, API calls, responses, any data processed by the agents, or secre
|
||||
When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected
|
||||
to provide deeper insights. This expanded data collection may include personal information if users have incorporated it into their crews or tasks.
|
||||
Users should carefully consider the content of their crews and tasks before enabling `share_crew`.
|
||||
Users can disable telemetry by setting the environment variable `CREWAI_DISABLE_TELEMETRY` to `true` or by setting `OTEL_SDK_DISABLED` to `true` (note that the latter disables all OpenTelemetry instrumentation globally).
|
||||
Users can disable CrewAI telemetry by setting `CREWAI_DISABLE_TELEMETRY` to `true`, `1`, `yes`, or `on` (any case). `OTEL_SDK_DISABLED` with the same values also disables CrewAI's exporter. The OpenTelemetry SDK itself still only honors `true` for disabling other instrumentation in the process.
|
||||
|
||||
### Examples:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (or `yes` / `on`) works the same as `true`. Unrecognized values are ignored and leave telemetry on.
|
||||
|
||||
### Isolation from your own OpenTelemetry setup
|
||||
|
||||
CrewAI's telemetry runs on its own private `TracerProvider` and never registers
|
||||
@@ -61,7 +63,7 @@ own tracer provider, which is independent of the one described here.
|
||||
| Yes | LLM Attributes | Includes: name, model_name, model, top_k, temperature, and class name of the LLM. All technical, non-personal data. |
|
||||
| Yes | Project Creation using crewAI CLI | Includes: that a new project was scaffolded by `crewai create`, which kind it was (`crew`, `json_crew` or `flow`), and the project ID minted for that new project and written into its own `pyproject.toml`. That is the new project's own ID, recorded separately from the `project_id` of the directory the command was run from — the two can differ. No project name, no file contents, no code. No personal data. |
|
||||
| Yes | Crew Deployment attempt using crewAI CLI | Includes: The fact a deploy is being made and crew id, whether it's trying to pull logs, and whether the deploy was started from a CLI command or from the run TUI. No project or crew contents. No personal data. |
|
||||
| Yes | Execution Environment | Includes: which AI coding assistant is running the process, if any (one of a fixed list such as `claude_code`, `codex`, `cursor`, or `unknown`), where the process runs (one of a fixed list such as `ci`, `container`, `serverless`, `interactive`), and the `project_id` from your `pyproject.toml` when one is configured. Detection reads only whether known environment variables are set, never their values. No personal data. |
|
||||
| Yes | Execution Environment | Includes: which AI coding assistant is running the process, if any (one of a fixed list such as `claude_code`, `codex`, `cursor`, or `unknown`), where the process runs (one of a fixed list such as `ci`, `container`, `serverless`, `interactive`), the `project_id` from your `pyproject.toml` when one is configured, and a coarse size band for the machine (one of `1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+`, or `unknown`). The band is a range, never the exact core count — the exact count is opt-in only, under Environment Information below. The size band comes from the host CPU count; assistant and location detection reads only whether known environment variables are set, never their values. No personal data. |
|
||||
| Yes | Flow Lifecycle Signals | Includes: that a flow started, whether it completed or failed, whether one of its methods failed, whether it paused for human input or feedback, whether the start was a resumed run, whether a conversation turn failed, how long the flow ran, and whether the flow is one CrewAI runs internally or one you wrote. The flow name is recorded, as it already is for flow creation and execution. When a flow or one of its methods fails, the **class name** of the exception is recorded (for example `TimeoutError`) so that failures can be diagnosed — never the error message, which can contain prompts, model output, file paths or credentials. Method names and flow state are never recorded. No personal data. |
|
||||
| Yes | Trace Sharing Signal | Includes: that a batch of traces was successfully shared with CrewAI AMP, and whether it was shared anonymously (before you have an account) or linked to your account. Like every span, it also carries the Execution Environment attributes described above (`project_id` when configured, the coding assistant, and the runtime). This row describes sharing telemetry only — not the trace contents or access granted by shared trace links. Trace contents, inputs, and outputs are never recorded on this signal. Before sharing traces, review secrets, personal data, and AMP redaction and retention settings. |
|
||||
| No | Agent's Expanded Data | Includes: goal description, backstory text, i18n prompt file identifier. Users should ensure no personal info is included in text fields. |
|
||||
|
||||
@@ -50,16 +50,15 @@ These tools integrate with AI and machine learning services to enhance your agen
|
||||
- **AI Safety**: Implement content moderation and safety checks
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
|
||||
@@ -736,7 +736,7 @@ memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Google Gemini 사용
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
memory = Memory(llm="gemini/gemini-3.7-flash")
|
||||
|
||||
# 사용자 정의 설정이 있는 사전 구성된 LLM 인스턴스 전달
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
|
||||
@@ -26,7 +26,7 @@ CrewAI의 기본 프롬프트는 많은 시나리오에서 잘 작동하지만,
|
||||
- **오류 처리** – agent가 실패, 예외, 또는 타임아웃에 어떻게 반응할지 지정합니다.
|
||||
- **도구별 prompt** – 도구가 호출되거나 사용되는 방법에 대한 상세 지침을 정의합니다.
|
||||
|
||||
이 요소들이 어떻게 구성되어 있는지 보려면 [CrewAI 저장소의 원본 prompt 템플릿](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
|
||||
이 요소들이 어떻게 구성되어 있는지 보려면 [CrewAI 저장소의 원본 prompt 템플릿](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
|
||||
|
||||
## 기본 시스템 지침 이해하기
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ research_crew/
|
||||
}
|
||||
```
|
||||
|
||||
`provider/model-id`를 `openai/gpt-4o`, `anthropic/claude-sonnet-4-6`, `gemini/gemini-2.0-flash-001` 같은 모델로 바꾸세요.
|
||||
`provider/model-id`를 `openai/gpt-4o`, `anthropic/claude-sonnet-4-6`, `gemini/gemini-3.7-flash` 같은 모델로 바꾸세요.
|
||||
|
||||
## 3단계: 태스크와 Crew 설정
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ crewai flow add-crew content-crew
|
||||
}
|
||||
```
|
||||
|
||||
`provider/model-id`를 사용하는 모델로 바꾸세요. 예: `openai/gpt-4o`, `gemini/gemini-2.0-flash-001`, `anthropic/claude-sonnet-4-6`.
|
||||
`provider/model-id`를 사용하는 모델로 바꾸세요. 예: `openai/gpt-4o`, `gemini/gemini-3.7-flash`, `anthropic/claude-sonnet-4-6`.
|
||||
|
||||
3. `src/guide_creator_flow/crews/content_crew/crew.jsonc`를 만듭니다:
|
||||
|
||||
@@ -481,7 +481,7 @@ Flow를 사용하면 간단하고 구조화된 응답이 필요할 때 언어
|
||||
|
||||
```python
|
||||
llm = LLM(
|
||||
model="model-id-here", # gpt-4o, gemini-2.0-flash, anthropic/claude...
|
||||
model="model-id-here", # gpt-4o, gemini/gemini-3.7-flash, anthropic/claude...
|
||||
response_format=GuideOutline
|
||||
)
|
||||
response = llm.call(messages=messages)
|
||||
|
||||
156
docs/edge/ko/guides/frontend/channels.mdx
Normal file
156
docs/edge/ko/guides/frontend/channels.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: Channels
|
||||
description: CopilotKit Channels SDK와 관리형 Intelligence 플랫폼으로 동일한 CrewAI 에이전트를 Slack 또는 Teams 봇으로 실행하세요.
|
||||
icon: messages
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 사용자가 이미 있는 곳에서 만나세요
|
||||
|
||||
[Overview](/edge/ko/guides/frontend/overview)에서 만든 CrewAI 에이전트는 반드시 웹 앱 뒤에서만 동작할 필요가 없습니다. 동일한 Crew 또는 Flow를 메시징 플랫폼 안에서 봇으로 실행할 수 있습니다. 다시 빌드할 필요도, 에이전트 로직을 두 번 복사할 필요도 없습니다. 에이전트는 [AG-UI 프로토콜](https://docs.ag-ui.com)을 통해 그대로 노출되고, **channel**이 Slack 또는 Microsoft Teams에서 이를 구동합니다.
|
||||
|
||||
CopilotKit의 [Channels SDK](https://docs.copilotkit.ai/slack)가 그 channel을 제공합니다. 작은 런타임에 `createChannel`을 선언하고 이를 CrewAI 에이전트에 연결하면, CopilotKit의 관리형 **Intelligence** 플랫폼이 메시징 제공자와의 연결을 중개합니다.
|
||||
|
||||
<Note>
|
||||
이 섹션의 나머지 내용과 달리 Channels는 **셀프 호스팅되지 않습니다**. Channels는 **CopilotKit Intelligence**를 통해 실행되며, 이는 설계상 Channels에 필수적인 서비스입니다(무료 티어 제공). Intelligence는 플랫폼 연결과 자격 증명을 보관하고, 각 플랫폼 이벤트를 수신하며, 해당 턴을 여러분의 channel 프로세스로 전달합니다. 여러분의 프로세스는 에이전트를 실행하고 응답을 다시 스트리밍합니다. Slack은 Intelligence 대시보드에서 한 번만 구성하면 되며, 플랫폼 자격 증명은 결코 여러분의 프로세스로 들어오지 않습니다. 에이전트, 도구, 상태는 온전히 여러분의 것으로 유지됩니다.
|
||||
</Note>
|
||||
|
||||
## 어떻게 맞물리는가
|
||||
|
||||
CrewAI 에이전트 서버에 관한 것은 아무것도 바뀌지 않습니다. Overview에서와 똑같이 AG-UI를 통해 Crew 또는 Flow를 계속 제공합니다. 여러분이 추가하는 것은 `@copilotkit/channels`로 빌드된 별도의 장시간 실행 Node 프로세스입니다. 이 프로세스는 `CopilotRuntime`에 channel을 등록하고, Intelligence에 연결하며, 메시지가 도착할 때마다 에이전트를 실행합니다.
|
||||
|
||||
```
|
||||
Slack / Teams ──► CopilotKit Intelligence ──► channel process (Node) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
channel 프로세스는 Intelligence 게이트웨이에 대한 지속적인 연결을 유지하므로, 장시간 실행되는 호스트가 필요합니다. 서버리스 요청 핸들러는 그 연결을 소유할 수 없습니다. CrewAI 서버는 동시에 Overview의 웹 프론트엔드를 계속 제공할 수 있습니다. 웹 앱과 channel은 하나의 AG-UI 엔드포인트에 연결된 두 개의 클라이언트일 뿐입니다.
|
||||
|
||||
## 통합 가이드
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="Channels 패키지 설치">
|
||||
|
||||
Channels SDK는 모든 것이 포함되어 있습니다. 모든 플랫폼이 하나의 패키지로 제공되며, 플랫폼별로 설치할 어댑터가 없습니다. channel을 호스팅하는 런타임 및 CrewAI AG-UI 클라이언트와 함께 다음을 추가하세요:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/channels @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Intelligence에서 Channel 생성">
|
||||
|
||||
[CopilotKit 대시보드](https://docs.copilotkit.ai/slack)에서 Channel을 생성하고 Slack을 연결하세요. Intelligence가 Slack 앱 생성 과정을 안내하고 그 자격 증명을 보관합니다. 그러면 여러분의 프로세스를 위한 두 개의 환경 변수가 남으며, 둘 다 대시보드에서 얻습니다:
|
||||
|
||||
```bash
|
||||
export INTELLIGENCE_API_KEY=... # authenticates the runtime with Intelligence (free tier available)
|
||||
export INTELLIGENCE_CHANNEL_ID=... # the Channel ID, matched by createChannel({ name })
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="channel 정의">
|
||||
|
||||
`createChannel`은 channel을 선언하고 에이전트를 연결합니다. 각 대화가 자신만의 세션을 갖도록 에이전트를 스레드별 팩토리로 빌드하되, Overview가 웹 런타임에서 사용하는 것과 동일한 `CrewAIAgent`를 여러분의 AG-UI 엔드포인트를 가리키도록 설정하세요. `identifyUser: "platform"`은 Intelligence가 각 플랫폼 사용자를 안정적인 신원에 매핑하도록 합니다.
|
||||
|
||||
```ts
|
||||
// channel.ts
|
||||
import { createChannel } from "@copilotkit/channels";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const channel = createChannel({
|
||||
name: process.env.INTELLIGENCE_CHANNEL_ID!, // must match the Channel ID in Intelligence
|
||||
identifyUser: "platform",
|
||||
// A fresh agent per conversation, pointed at your CrewAI AG-UI endpoint.
|
||||
agent: (threadId) => {
|
||||
const agent = new CrewAIAgent({ url: "http://localhost:8000/recipe" });
|
||||
agent.threadId = threadId;
|
||||
return agent;
|
||||
},
|
||||
});
|
||||
|
||||
// A mention subscribes the thread and runs the agent; afterwards every message
|
||||
// in a subscribed thread runs it without needing another mention.
|
||||
channel.onMention(async ({ thread }) => {
|
||||
await thread.subscribe();
|
||||
await thread.runAgent();
|
||||
});
|
||||
|
||||
channel.onMessage(async ({ thread }) => {
|
||||
if (await thread.isSubscribed()) await thread.runAgent();
|
||||
});
|
||||
|
||||
export { channel };
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="런타임에 channel 등록">
|
||||
|
||||
Intelligence 게이트웨이와 여러분의 channel로 `CopilotRuntime`을 생성한 다음, `createCopilotNodeListener`로 이를 제공하세요. `agents` 맵은 비어 있는 상태로 둡니다. channel이 자신의 에이전트를 제공하기 때문입니다. 잘못된 구성이 시작 시 명확하게 실패하도록 channel이 준비될 때까지 기다리세요.
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { createServer } from "node:http";
|
||||
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
|
||||
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
|
||||
import { channel } from "./channel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {}, // the channel supplies its own agent; no web-facing agents needed
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY!, // free tier available
|
||||
}),
|
||||
channels: [channel],
|
||||
});
|
||||
|
||||
const listener = createCopilotNodeListener({ runtime });
|
||||
await listener.channels?.ready({ timeoutMs: 15_000 });
|
||||
|
||||
createServer(listener).listen(3123, () => {
|
||||
console.log("Channels runtime listening on port 3123");
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="channel 런타임 실행">
|
||||
|
||||
CrewAI 에이전트 서버와 함께 시작하세요:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1 — CrewAI agent server
|
||||
npx tsx server.ts # terminal 2 — Channels runtime
|
||||
```
|
||||
|
||||
Slack 또는 Teams에서 봇을 멘션하면 Crew 또는 Flow를 실행하고 응답을 스레드로 다시 스트리밍합니다. 스레드는 구독된 상태로 유지되므로 후속 메시지는 또다시 멘션할 필요 없이 실행됩니다.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 이벤트 모델
|
||||
|
||||
channel은 핸들러로 플랫폼 이벤트에 반응하며, 각 핸들러는 몇 가지 메서드로 구동하는 `thread`를 받습니다:
|
||||
|
||||
- **`channel.onMention`**은 사용자가 봇을 @-멘션할 때 발생합니다. `thread.subscribe()`를 호출해 스레드에 참여한 다음, `thread.runAgent()`로 멘션에 대해 CrewAI 에이전트를 실행하세요.
|
||||
- **`channel.onMessage`**는 봇이 볼 수 있는 스레드의 모든 메시지에서 발생합니다. `thread.isSubscribed()`로 게이트를 걸어 에이전트가 참여한 곳에서만 응답하도록 한 다음, `thread.runAgent()`를 호출하세요.
|
||||
- **`thread.runAgent()`**는 현재 턴에 대해 연결된 CrewAI 에이전트를 실행하고 그 출력을 channel로 다시 스트리밍합니다. 에이전트가 실행할 텍스트를 재정의하려면 `{ prompt }`를 전달하세요.
|
||||
|
||||
여러분의 에이전트는 일반적인 AG-UI `RunAgentInput`을 받고 일반적인 AG-UI 이벤트를 방출합니다. 플랫폼 메커니즘은 channel 뒤에 머무르므로, 동일한 Crew 또는 Flow가 모든 플랫폼에서 변경 없이 실행됩니다. channel은 환영 인사, 인터럽트, 명령, 반응, 모달을 위한 핸들러도 노출합니다. 전체 표면은 [`Channel` 레퍼런스](https://docs.copilotkit.ai/reference/channels/classes/Channel)를 참조하세요.
|
||||
|
||||
## 플랫폼 지원
|
||||
|
||||
관리형 Intelligence 경로는 현재 **Slack**과 **Microsoft Teams**를 지원합니다. 동일한 channel 코드가 양쪽에서 실행되며, `message.platform` / `thread.platform`이 원래의 출처를 보고합니다. 다른 플랫폼(Discord, Telegram, WhatsApp)은 관리형 경로가 아니라 개발자가 운영하는 **direct adapters**를 통해 연결됩니다. 여러분 자신의 프로세스가 플랫폼 자격 증명과 전송을 보유합니다. 현재 지원 플랫폼 목록과 플랫폼별 설정은 [CopilotKit Channels 문서](https://docs.copilotkit.ai/slack)를 확인하세요.
|
||||
|
||||
## 관련 항목
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Frontend Overview" icon="browser" href="/edge/ko/guides/frontend/overview">
|
||||
Crew 또는 Flow를 AG-UI를 통해 제공하세요. 모든 channel이 그 위에 세워지는 토대입니다.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
실행 도중 사용자 승인이나 입력을 수집하기 위해 에이전트를 일시 중지하세요.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
238
docs/edge/ko/guides/frontend/overview.mdx
Normal file
238
docs/edge/ko/guides/frontend/overview.mdx
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: Frontend Overview
|
||||
description: CopilotKit과 AG-UI 프로토콜로 CrewAI 에이전트를 위한 인터랙티브 사용자 인터페이스를 구축하세요.
|
||||
icon: browser
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## 에이전트에 사용자 인터페이스를 부여하세요
|
||||
|
||||
CrewAI는 여러분의 에이전트를 실행합니다. [CopilotKit](https://copilotkit.ai)은 그 에이전트에 프론트엔드를 제공합니다. 이 둘을 함께 사용하면 사용자가 Crew 또는 Flow와 대화하고, 실시간으로 작동하는 모습을 지켜보고, 그 결정을 승인하며, 출력을 장황한 텍스트 대신 살아 있는 UI로 렌더링하여 볼 수 있는 애플리케이션을 구축할 수 있습니다.
|
||||
|
||||
이 둘은 [AG-UI 프로토콜](https://docs.ag-ui.com)을 통해 연결됩니다. `ag-ui-crewai` 패키지는 어떤 Crew나 Flow든 AG-UI 엔드포인트로 노출합니다. CopilotKit의 React 훅과 컴포넌트가 그 엔드포인트를 소비합니다. 이를 통해 채팅 상자를 훨씬 뛰어넘는 경험이 열립니다:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Generative UI" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
에이전트 도구 호출과 상태를 여러분만의 React 컴포넌트로 렌더링하세요.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
실행 도중 사용자 승인이나 입력을 수집하기 위해 에이전트를 일시 중지하세요.
|
||||
</Card>
|
||||
<Card title="Shared State" icon="arrows-rotate" href="/edge/en/guides/frontend/shared-state">
|
||||
에이전트 상태와 앱 UI를 양방향으로 동기화하세요.
|
||||
</Card>
|
||||
<Card title="Channels" icon="messages" href="/edge/ko/guides/frontend/channels">
|
||||
동일한 에이전트를 Slack, Discord 또는 Teams 봇으로 실행하세요.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
이 가이드는 Crew 또는 Flow를 Next.js 프론트엔드와 처음부터 끝까지 연동시킵니다. 이 섹션의 나머지 내용은 여기서 설정한 앱을 기반으로 합니다.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
세 가지 구성 요소가 있습니다:
|
||||
|
||||
1. **CrewAI 에이전트 서버** — AG-UI를 통해 Crew 또는 Flow를 제공하는 Python 프로세스(FastAPI + `ag-ui-crewai`).
|
||||
2. **CopilotKit 런타임** — 에이전트를 등록하고 요청을 프록시하는 Next.js 라우트.
|
||||
3. **React 프론트엔드** — `<CopilotKit>` 프로바이더와 채팅 및 generative-UI 컴포넌트.
|
||||
|
||||
```
|
||||
React app ──► CopilotKit runtime (/api/copilotkit) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
<Note>
|
||||
이 가이드는 **셀프 호스팅** 경로를 다룹니다. `ag-ui-crewai`로 CrewAI 에이전트 서버를 직접 실행하며, 관리형 서비스 없이 로컬에서 동작합니다. CopilotKit은 호스팅된 스레드와 인스펙터를 갖춘 **관리형** 경로(CopilotKit Cloud / Enterprise Intelligence)도 제공합니다. 그 방식을 원한다면 [CopilotKit CrewAI 퀵스타트](https://docs.copilotkit.ai/crewai-crews/quickstart)를 참조하세요. 이 섹션의 프론트엔드 코드는 어느 쪽이든 동일합니다. 에이전트를 호스팅하고 등록하는 방식만 다릅니다.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
CrewAI는 AG-UI 뒤에서 세 가지 형태로 실행됩니다: 일반 **Flows**(이 가이드 전반에서 사용), **[Conversational Flows](/edge/en/guides/frontend/conversational-flows)**(네이티브, 세션 인식, 턴 기반, 완전한 기능 동등성), 그리고 **Crews**(기본 채팅). 이 섹션의 프론트엔드는 이들 전반에서 동일합니다. 백엔드 작성과 등록만 다릅니다.
|
||||
</Note>
|
||||
|
||||
## 통합 가이드
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="AG-UI를 통해 에이전트 제공">
|
||||
|
||||
통합 패키지를 CrewAI 프로젝트에 설치하세요:
|
||||
|
||||
```bash
|
||||
pip install ag-ui-crewai
|
||||
```
|
||||
|
||||
FastAPI 앱에서 에이전트를 노출하세요. Flows는 `add_crewai_flow_fastapi_endpoint`를, Crews는 `add_crewai_crew_fastapi_endpoint`를 사용합니다. 원하는 만큼 등록할 수 있으며, 각각 자신의 경로에 배치됩니다.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Flow
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
|
||||
from my_agents.recipe_flow import RecipeFlow
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_flow_fastapi_endpoint(
|
||||
app=app,
|
||||
flow=RecipeFlow(),
|
||||
path="/recipe",
|
||||
)
|
||||
```
|
||||
|
||||
```python Crew
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_crew_fastapi_endpoint
|
||||
from my_agents.research_crew import ResearchCrew
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_crew_fastapi_endpoint(
|
||||
app=app,
|
||||
crew=ResearchCrew().crew(),
|
||||
path="/research",
|
||||
)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
실행하세요:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000
|
||||
```
|
||||
|
||||
<Note>
|
||||
서버를 시작하기 전에 LLM 제공자를 위한 환경 변수(예: `OPENAI_API_KEY`)를 설정하세요.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Next.js 앱 생성">
|
||||
|
||||
아직 프론트엔드가 없다면 하나를 스캐폴딩하세요:
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest my-app
|
||||
cd my-app
|
||||
```
|
||||
|
||||
CopilotKit과 CrewAI AG-UI 클라이언트를 설치하세요:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="CopilotKit 런타임 추가">
|
||||
|
||||
CrewAI 에이전트를 CopilotKit 런타임에 등록하는 라우트를 생성하세요. 각 에이전트는 `CrewAIAgent`를 통해 Python 서버의 경로를 가리킵니다.
|
||||
|
||||
```ts
|
||||
// app/api/copilotkit/route.ts
|
||||
import {
|
||||
CopilotRuntime,
|
||||
InMemoryAgentRunner,
|
||||
createCopilotEndpoint,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
recipe: new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
},
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
const handler = handle(app);
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="프로바이더로 앱 감싸기">
|
||||
|
||||
`<CopilotKit>`을 런타임 라우트로 가리키고 등록한 에이전트의 이름을 지정하세요.
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="recipe">
|
||||
<YourApp />
|
||||
<CopilotSidebar agentId="recipe" labels={{ modalHeaderTitle: "Assistant" }} />
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="실행">
|
||||
|
||||
두 프로세스를 모두 시작하고 앱을 여세요. 이제 사이드바에서 채팅하면 Crew 또는 Flow가 실행됩니다.
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1
|
||||
npm run dev # terminal 2
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## 채팅 UI 옵션
|
||||
|
||||
CopilotKit은 서로 교체 가능한 세 가지 채팅 표면을 제공합니다. 컴포넌트만 바꾸면 되며, 연결 방식은 동일합니다.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx Sidebar
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotSidebar agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Popup
|
||||
import { CopilotPopup } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotPopup agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Inline
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotChat agentId="recipe" />
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## 다음으로 갈 곳
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Generative UI" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
도구 호출과 에이전트 상태를 커스텀 컴포넌트로 렌더링하세요.
|
||||
</Card>
|
||||
<Card title="Frontend Actions" icon="bolt" href="/edge/en/guides/frontend/frontend-actions">
|
||||
에이전트가 브라우저에서 실행되는 함수를 호출하도록 하세요.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
에이전트 동작을 사용자 승인 뒤에 두세요.
|
||||
</Card>
|
||||
<Card title="Predictive State" icon="gauge-high" href="/edge/en/guides/frontend/predictive-state-updates">
|
||||
에이전트가 작동하는 동안 진행 중인 상태를 UI로 스트리밍하세요.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -141,7 +141,7 @@ OpenAI 호환 LLM에 연결하려면 환경 변수를 사용하거나 LLM 클래
|
||||
# Gemini의 OpenAI 호환 API 예시입니다.
|
||||
os.environ["OPENAI_API_KEY"] = "your-gemini-key" # AIza...로 시작해야 합니다.
|
||||
os.environ["OPENAI_API_BASE"] = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-2.0-flash" # Gemini 모델을 여기에 추가하세요. openai/ 하위에 위치.
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-3.7-flash" # Gemini 모델을 여기에 추가하세요. openai/ 하위에 위치.
|
||||
```
|
||||
</CodeGroup>
|
||||
</Tab>
|
||||
@@ -159,7 +159,7 @@ OpenAI 호환 LLM에 연결하려면 환경 변수를 사용하거나 LLM 클래
|
||||
```python Google
|
||||
# Gemini의 OpenAI 호환 API 예시
|
||||
llm = LLM(
|
||||
model="openai/gemini-2.0-flash",
|
||||
model="openai/gemini-3.7-flash",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
api_key="your-gemini-key", # AIza...로 시작해야 합니다.
|
||||
)
|
||||
|
||||
@@ -145,7 +145,7 @@ planning agent는 복잡한 전략적 사고와 다단계 분석을 처리할
|
||||
from crewai import Agent, Task, Crew, LLM
|
||||
|
||||
# High-capability reasoning model for strategic planning
|
||||
manager_llm = LLM(model="gemini-2.5-flash-preview-05-20", temperature=0.1)
|
||||
manager_llm = LLM(model="gemini/gemini-3.7-flash", temperature=0.1)
|
||||
|
||||
# Creative model for content generation
|
||||
content_llm = LLM(model="claude-3-5-sonnet-20241022", temperature=0.7)
|
||||
@@ -411,7 +411,7 @@ tech_writer = Agent(
|
||||
# Manager 또는 coordination agent
|
||||
manager_agent = Agent(
|
||||
role="Project Manager",
|
||||
llm=LLM(model="gemini-2.5-flash-preview-05-20"), # 조율을 위한 프리미엄
|
||||
llm=LLM(model="gemini/gemini-3.7-flash"), # 조율을 위한 프리미엄
|
||||
# ... 나머지 설정
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ CrewAI는 익명 텔레메트리를 활용하여 사용 통계를 수집하며,
|
||||
`share_crew` 기능이 활성화되면, 보다 심층적인 통찰을 제공하기 위해 작업 설명, 에이전트의 배경 이야기나 목표, 기타 특정 속성 등 상세한 데이터가 수집됩니다.
|
||||
이 확대된 데이터 수집에는 사용자가 crew나 작업에 개인정보를 포함한 경우, 개인정보가 포함될 수 있습니다.
|
||||
사용자는 `share_crew`를 활성화하기 전에 crew와 작업의 내용을 신중하게 검토해야 합니다.
|
||||
사용자는 환경 변수 `CREWAI_DISABLE_TELEMETRY`를 `true`로 설정하거나, `OTEL_SDK_DISABLED`를 `true`로 설정하여 텔레메트리를 비활성화할 수 있습니다(후자의 경우 전체 OpenTelemetry 계측이 전역에서 비활성화된다는 점에 유의하십시오).
|
||||
사용자는 `CREWAI_DISABLE_TELEMETRY`를 `true`, `1`, `yes`, `on` 중 하나로 설정하여 CrewAI 텔레메트리를 비활성화할 수 있습니다(대소문자 무관). 같은 값의 `OTEL_SDK_DISABLED`도 CrewAI exporter를 끕니다. 프로세스 내 다른 OpenTelemetry 계측을 끄려면 OpenTelemetry SDK는 여전히 `true`만 인식합니다.
|
||||
|
||||
### 예시:
|
||||
```python
|
||||
@@ -33,6 +33,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1`(`yes` / `on`도 동일)은 `true`와 같습니다. 인식되지 않는 값은 무시되며 텔레메트리는 켜진 채로 남습니다.
|
||||
|
||||
### 사용자 OpenTelemetry 설정과의 격리
|
||||
|
||||
CrewAI의 telemetry는 자체 전용 `TracerProvider`에서 실행되며 자신을 전역
|
||||
@@ -59,7 +61,7 @@ provider로 등록하지 않습니다. 이를 통해 양방향이 분리됩니
|
||||
| 예 | LLM 속성 | LLM의 이름, model_name, 모델, top_k, temperature 및 클래스명이 포함됩니다. 모두 기술적이고 비개인 정보입니다. |
|
||||
| 예 | crewAI CLI를 통한 프로젝트 생성 | 포함 항목: `crewai create`로 새 프로젝트가 생성되었다는 사실, 그 종류(`crew`, `json_crew` 또는 `flow`), 그리고 그 새 프로젝트에 발급되어 해당 프로젝트의 `pyproject.toml`에 기록된 프로젝트 ID. 이는 새 프로젝트 자체의 ID이며, 명령을 실행한 디렉터리의 `project_id`와는 별개로 기록됩니다 — 두 값은 다를 수 있습니다. 프로젝트 이름, 파일 내용, 코드는 기록되지 않습니다. 개인 정보 없음. |
|
||||
| 예 | crewAI CLI를 통한 Crew 배포 시도 | 포함 항목: 배포가 시도되고 있다는 사실과 crew id, 로그를 가져오려고 하는지 여부, 그리고 배포가 CLI 명령에서 시작되었는지 실행 TUI에서 시작되었는지 여부. 프로젝트나 crew의 내용은 기록되지 않습니다. 개인 정보 없음. |
|
||||
| 예 | 실행 환경 | 포함: 프로세스를 실행 중인 AI 코딩 어시스턴트(있는 경우, `claude_code`, `codex`, `cursor`, `unknown` 등 고정 목록 중 하나), 프로세스가 실행되는 위치(`ci`, `container`, `serverless`, `interactive` 등 고정 목록 중 하나), 그리고 `pyproject.toml`에 설정된 경우 `project_id`. 감지는 알려진 환경 변수의 설정 여부만 확인하며 값은 읽지 않음. 개인 데이터 없음. |
|
||||
| 예 | 실행 환경 | 포함: 프로세스를 실행 중인 AI 코딩 어시스턴트(있는 경우, `claude_code`, `codex`, `cursor`, `unknown` 등 고정 목록 중 하나), 프로세스가 실행되는 위치(`ci`, `container`, `serverless`, `interactive` 등 고정 목록 중 하나), `pyproject.toml`에 설정된 경우 `project_id`, 그리고 머신 크기의 대략적인 구간(`1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+`, `unknown` 중 하나). 구간은 범위이며 정확한 코어 수는 절대 포함하지 않습니다 — 정확한 코어 수는 아래 환경 정보에서 옵트인한 경우에만 수집됩니다. 크기 구간은 호스트 CPU 수에서 가져오며, 어시스턴트와 실행 위치 감지는 알려진 환경 변수의 설정 여부만 확인하고 값은 읽지 않음. 개인 데이터 없음. |
|
||||
| 예 | Flow 라이프사이클 신호 | 포함 항목: flow의 시작, 완료 또는 실패 여부, 해당 메서드의 실패 여부, 사람의 입력이나 피드백을 위해 일시 중지되었는지 여부, 해당 시작이 재개된 실행이었는지 여부, 대화 턴의 실패 여부, flow 실행 시간, 그리고 해당 flow가 CrewAI가 내부적으로 실행하는 것인지 사용자가 작성한 것인지 여부. flow 이름은 flow 생성 및 실행에서와 마찬가지로 기록됩니다. flow 또는 해당 메서드가 실패하면 장애 진단을 위해 예외의 **클래스 이름**(예: `TimeoutError`)이 기록되며, 프롬프트·모델 출력·파일 경로·자격 증명이 포함될 수 있는 오류 메시지는 절대 기록되지 않습니다. 메서드 이름과 flow 상태는 절대 기록되지 않습니다. 개인 정보 없음. |
|
||||
| 예 | 트레이스 공유 신호 | 포함 항목: 트레이스 배치가 CrewAI AMP에 성공적으로 공유되었는지 여부와, 익명으로(계정 생성 전) 공유되었는지 또는 계정에 연결되어 공유되었는지 여부. 모든 span과 마찬가지로 위에서 설명한 실행 환경 속성(구성된 경우 `project_id`, 코딩 어시스턴트, 런타임)도 함께 기록됩니다. 이 행은 공유 텔레메트리만 설명하며 — 트레이스 내용이나 공유된 트레이스 링크로 부여되는 접근 권한은 설명하지 않습니다. 트레이스 내용, 입력, 출력은 이 신호에는 기록되지 않습니다. 트레이스를 공유하기 전에 비밀 정보, 개인 데이터, AMP 편집 및 보존 설정을 검토하세요. |
|
||||
| 아니오 | 에이전트 확장 데이터 | 목표 설명, 배경 이야기 텍스트, i18n 프롬프트 파일 식별자가 포함됩니다. 사용자들은 텍스트 필드에 개인 정보가 포함되지 않도록 해야 합니다. |
|
||||
|
||||
@@ -48,17 +48,16 @@ mode: "wide"
|
||||
- **AI 안전성**: 콘텐츠 모더레이션 및 안전성 점검 구현
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
```
|
||||
@@ -736,7 +736,7 @@ memory = Memory(llm="anthropic/claude-3-haiku-20240307")
|
||||
memory = Memory(llm="ollama/llama3.2")
|
||||
|
||||
# Usar Google Gemini
|
||||
memory = Memory(llm="gemini/gemini-2.0-flash")
|
||||
memory = Memory(llm="gemini/gemini-3.7-flash")
|
||||
|
||||
# Passar uma instância LLM pré-configurada com configurações customizadas
|
||||
llm = LLM(model="gpt-4o", temperature=0)
|
||||
|
||||
@@ -26,7 +26,7 @@ Nos bastidores, o CrewAI adota um sistema de prompt modular que pode ser amplame
|
||||
- **Tratamento de erros** – Definem como os agentes respondem a falhas, exceções ou timeouts.
|
||||
- **Prompts específicos de ferramentas** – Definem instruções detalhadas para como as ferramentas são invocadas ou utilizadas.
|
||||
|
||||
Confira os [templates de prompt originais no repositório do CrewAI](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) para ver como esses elementos são organizados. A partir daí, você pode sobrescrever ou adaptar conforme necessário para desbloquear comportamentos avançados.
|
||||
Confira os [templates de prompt originais no repositório do CrewAI](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) para ver como esses elementos são organizados. A partir daí, você pode sobrescrever ou adaptar conforme necessário para desbloquear comportamentos avançados.
|
||||
|
||||
## Entendendo as Instruções de Sistema Padrão
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Substitua o arquivo gerado `agents/researcher.jsonc` e adicione `agents/analyst.
|
||||
}
|
||||
```
|
||||
|
||||
Substitua `provider/model-id` pelo modelo usado, como `openai/gpt-4o`, `anthropic/claude-sonnet-4-6` ou `gemini/gemini-2.0-flash-001`.
|
||||
Substitua `provider/model-id` pelo modelo usado, como `openai/gpt-4o`, `anthropic/claude-sonnet-4-6` ou `gemini/gemini-3.7-flash`.
|
||||
|
||||
## Etapa 3: Definir tarefas e configurações
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ Agora, vamos configurar o crew de redatores com JSONC. Vamos definir dois agente
|
||||
}
|
||||
```
|
||||
|
||||
Substitua `provider/model-id` pelo modelo que você usa, como `openai/gpt-4o`, `gemini/gemini-2.0-flash-001` ou `anthropic/claude-sonnet-4-6`.
|
||||
Substitua `provider/model-id` pelo modelo que você usa, como `openai/gpt-4o`, `gemini/gemini-3.7-flash` ou `anthropic/claude-sonnet-4-6`.
|
||||
|
||||
3. Crie `src/guide_creator_flow/crews/content_crew/crew.jsonc`:
|
||||
|
||||
@@ -481,7 +481,7 @@ Flows permitem que você faça chamadas diretas a modelos de linguagem quando pr
|
||||
|
||||
```python
|
||||
llm = LLM(
|
||||
model="model-id-here", # gpt-4o, gemini-2.0-flash, anthropic/claude...
|
||||
model="model-id-here", # gpt-4o, gemini/gemini-3.7-flash, anthropic/claude...
|
||||
response_format=GuideOutline
|
||||
)
|
||||
response = llm.call(messages=messages)
|
||||
|
||||
156
docs/edge/pt-BR/guides/frontend/channels.mdx
Normal file
156
docs/edge/pt-BR/guides/frontend/channels.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: Channels
|
||||
description: Execute o mesmo agente CrewAI como um bot do Slack ou Teams com o Channels SDK do CopilotKit e a plataforma gerenciada Intelligence.
|
||||
icon: messages
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Encontre seus usuários onde eles já estão
|
||||
|
||||
O agente CrewAI que você construiu na [Visão geral](/edge/pt-BR/guides/frontend/overview) não precisa viver por trás de um web app. O mesmo Crew ou Flow pode rodar como um bot dentro de uma plataforma de mensagens. Sem reconstruir, sem uma segunda cópia da lógica do seu agente: o agente permanece exposto pelo [protocolo AG-UI](https://docs.ag-ui.com), e um **channel** o aciona a partir do Slack ou do Microsoft Teams.
|
||||
|
||||
O [Channels SDK](https://docs.copilotkit.ai/slack) do CopilotKit fornece esse channel. Você declara um `createChannel` em um pequeno runtime, aponta-o para o seu agente CrewAI, e a plataforma gerenciada **Intelligence** do CopilotKit intermedia a conexão com o provedor de mensagens.
|
||||
|
||||
<Note>
|
||||
Diferentemente do restante desta seção, Channels **não é self-hosted**. Ele roda através do **CopilotKit Intelligence** — uma superfície obrigatória para Channels, por design (há um plano gratuito disponível). O Intelligence detém a conexão com a plataforma e as credenciais, recebe cada evento da plataforma e entrega o turno ao processo do seu channel; seu processo executa o agente e transmite a resposta de volta. Você configura o Slack uma vez no painel do Intelligence, e as credenciais da plataforma nunca entram no seu processo. Seu agente, suas tools e seu estado continuam sendo seus.
|
||||
</Note>
|
||||
|
||||
## Como tudo se encaixa
|
||||
|
||||
Nada muda no servidor do seu agente CrewAI. Ele continua servindo o seu Crew ou Flow por AG-UI exatamente como na Visão geral. O que você adiciona é um processo Node separado, de longa duração, construído com `@copilotkit/channels`: ele registra um channel no `CopilotRuntime`, conecta-se ao Intelligence e executa o seu agente sempre que chega uma mensagem.
|
||||
|
||||
```
|
||||
Slack / Teams ──► CopilotKit Intelligence ──► channel process (Node) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
O processo do channel mantém uma conexão persistente com o gateway do Intelligence, então ele precisa de um host de longa duração — um handler de requisições serverless não consegue ser dono dessa conexão. Seu servidor CrewAI pode continuar servindo o frontend web da Visão geral ao mesmo tempo: o web app e o channel são apenas dois clientes de um único endpoint AG-UI.
|
||||
|
||||
## Guia de integração
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="Instale os pacotes do Channels">
|
||||
|
||||
O Channels SDK vem com tudo incluído — cada plataforma é entregue no mesmo pacote, sem nenhum adaptador por plataforma para instalar. Adicione-o junto ao runtime que hospeda o channel e ao cliente AG-UI do CrewAI:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/channels @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Crie um Channel no Intelligence">
|
||||
|
||||
No [painel do CopilotKit](https://docs.copilotkit.ai/slack), crie um Channel e conecte o Slack — o Intelligence guia você na criação do app do Slack e detém suas credenciais. Isso deixa duas variáveis de ambiente para o seu processo, ambas vindas do painel:
|
||||
|
||||
```bash
|
||||
export INTELLIGENCE_API_KEY=... # authenticates the runtime with Intelligence (free tier available)
|
||||
export INTELLIGENCE_CHANNEL_ID=... # the Channel ID, matched by createChannel({ name })
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Defina o channel">
|
||||
|
||||
`createChannel` declara o channel e anexa o seu agente. Construa o agente como uma factory por thread, para que cada conversa ganhe sua própria sessão, usando o mesmo `CrewAIAgent` que a Visão geral usa no runtime web, apontado para o seu endpoint AG-UI. `identifyUser: "platform"` permite que o Intelligence mapeie cada usuário da plataforma para uma identidade estável.
|
||||
|
||||
```ts
|
||||
// channel.ts
|
||||
import { createChannel } from "@copilotkit/channels";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const channel = createChannel({
|
||||
name: process.env.INTELLIGENCE_CHANNEL_ID!, // must match the Channel ID in Intelligence
|
||||
identifyUser: "platform",
|
||||
// A fresh agent per conversation, pointed at your CrewAI AG-UI endpoint.
|
||||
agent: (threadId) => {
|
||||
const agent = new CrewAIAgent({ url: "http://localhost:8000/recipe" });
|
||||
agent.threadId = threadId;
|
||||
return agent;
|
||||
},
|
||||
});
|
||||
|
||||
// A mention subscribes the thread and runs the agent; afterwards every message
|
||||
// in a subscribed thread runs it without needing another mention.
|
||||
channel.onMention(async ({ thread }) => {
|
||||
await thread.subscribe();
|
||||
await thread.runAgent();
|
||||
});
|
||||
|
||||
channel.onMessage(async ({ thread }) => {
|
||||
if (await thread.isSubscribed()) await thread.runAgent();
|
||||
});
|
||||
|
||||
export { channel };
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Registre o channel no runtime">
|
||||
|
||||
Crie um `CopilotRuntime` com o gateway do Intelligence e o seu channel, e então sirva-o com `createCopilotNodeListener`. O mapa `agents` permanece vazio — o channel fornece seu próprio agente. Aguarde o channel ficar pronto, para que uma configuração quebrada faça a inicialização falhar de forma visível.
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { createServer } from "node:http";
|
||||
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
|
||||
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
|
||||
import { channel } from "./channel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {}, // the channel supplies its own agent; no web-facing agents needed
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY!, // free tier available
|
||||
}),
|
||||
channels: [channel],
|
||||
});
|
||||
|
||||
const listener = createCopilotNodeListener({ runtime });
|
||||
await listener.channels?.ready({ timeoutMs: 15_000 });
|
||||
|
||||
createServer(listener).listen(3123, () => {
|
||||
console.log("Channels runtime listening on port 3123");
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Execute o runtime do channel">
|
||||
|
||||
Inicie-o junto ao servidor do seu agente CrewAI:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1 — CrewAI agent server
|
||||
npx tsx server.ts # terminal 2 — Channels runtime
|
||||
```
|
||||
|
||||
Mencione o bot no Slack ou no Teams e ele executa o seu Crew ou Flow, transmitindo a resposta de volta para a thread. A thread permanece inscrita, então mensagens de acompanhamento rodam sem outra menção.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## O modelo de eventos
|
||||
|
||||
Um channel reage a eventos da plataforma com handlers, e cada handler recebe uma `thread` que você aciona com alguns métodos:
|
||||
|
||||
- **`channel.onMention`** dispara quando um usuário @-menciona o bot. Chame `thread.subscribe()` para entrar na thread, e então `thread.runAgent()` para executar o seu agente CrewAI na menção.
|
||||
- **`channel.onMessage`** dispara em cada mensagem de uma thread que o bot consegue ver. Restrinja com `thread.isSubscribed()` para que o agente só responda onde tiver entrado, e então `thread.runAgent()`.
|
||||
- **`thread.runAgent()`** executa o agente CrewAI anexado para o turno atual e transmite a saída dele de volta para o channel. Passe `{ prompt }` para sobrescrever o texto sobre o qual o agente roda.
|
||||
|
||||
Seu agente recebe um `RunAgentInput` comum do AG-UI e emite eventos comuns do AG-UI; as mecânicas da plataforma ficam por trás do channel, então o mesmo Crew ou Flow roda sem alterações em todas as plataformas. O channel também expõe handlers para boas-vindas, interrupções, comandos, reações e modais — consulte a [referência de `Channel`](https://docs.copilotkit.ai/reference/channels/classes/Channel) para conhecer toda a superfície.
|
||||
|
||||
## Suporte a plataformas
|
||||
|
||||
O caminho gerenciado do Intelligence cobre **Slack** e **Microsoft Teams** hoje — o mesmo código de channel roda em qualquer um dos dois, e `message.platform` / `thread.platform` reportam a origem nativa. Outras plataformas (Discord, Telegram, WhatsApp) são alcançadas através de **adaptadores diretos** operados pelo desenvolvedor, em vez do caminho gerenciado — o seu próprio processo detém as credenciais da plataforma e o transporte. Consulte a [documentação de Channels do CopilotKit](https://docs.copilotkit.ai/slack) para a lista atual de plataformas e a configuração por plataforma.
|
||||
|
||||
## Relacionados
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Visão geral do Frontend" icon="browser" href="/edge/pt-BR/guides/frontend/overview">
|
||||
Sirva o seu Crew ou Flow por AG-UI — a base sobre a qual todo channel é construído.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
Pause o agente para coletar aprovação ou input do usuário no meio da execução.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
238
docs/edge/pt-BR/guides/frontend/overview.mdx
Normal file
238
docs/edge/pt-BR/guides/frontend/overview.mdx
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: Frontend Overview
|
||||
description: Construa interfaces de usuário interativas para seus agentes CrewAI com o CopilotKit e o protocolo AG-UI.
|
||||
icon: browser
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Dê uma interface de usuário aos seus agentes
|
||||
|
||||
O CrewAI executa seus agentes. O [CopilotKit](https://copilotkit.ai) dá a eles um frontend. Juntos, eles permitem que você construa aplicações em que os usuários conversam com um Crew ou Flow, o observam trabalhar em tempo real, aprovam suas decisões e veem sua saída renderizada como UI ao vivo, em vez de paredes de texto.
|
||||
|
||||
Os dois se conectam através do [protocolo AG-UI](https://docs.ag-ui.com). O pacote `ag-ui-crewai` expõe qualquer Crew ou Flow como um endpoint AG-UI. Os hooks e componentes React do CopilotKit consomem esse endpoint. Isso desbloqueia experiências que vão muito além de uma caixa de chat:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Generative UI" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
Renderize as chamadas de tool e o estado do agente como seus próprios componentes React.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
Pause o agente para coletar aprovação ou input do usuário no meio da execução.
|
||||
</Card>
|
||||
<Card title="Shared State" icon="arrows-rotate" href="/edge/en/guides/frontend/shared-state">
|
||||
Mantenha o estado do agente e a UI do seu app em sincronia bidirecional.
|
||||
</Card>
|
||||
<Card title="Channels" icon="messages" href="/edge/pt-BR/guides/frontend/channels">
|
||||
Execute o mesmo agente como um bot do Slack, Discord ou Teams.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Este guia coloca um Crew ou Flow conversando com um frontend Next.js de ponta a ponta. O restante da seção se apoia no app que você configura aqui.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
Há três peças:
|
||||
|
||||
1. **CrewAI agent server** — um processo Python que serve o seu Crew ou Flow por AG-UI (FastAPI + `ag-ui-crewai`).
|
||||
2. **CopilotKit runtime** — uma rota Next.js que registra o seu agente e faz o proxy das requisições para ele.
|
||||
3. **React frontend** — o provider `<CopilotKit>` mais os componentes de chat e de generative UI.
|
||||
|
||||
```
|
||||
React app ──► CopilotKit runtime (/api/copilotkit) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
<Note>
|
||||
Este guia cobre o caminho **self-hosted**: você mesmo executa o servidor do agente CrewAI com `ag-ui-crewai`, e ele funciona localmente sem nenhum serviço gerenciado. O CopilotKit também oferece um caminho **gerenciado** (CopilotKit Cloud / Enterprise Intelligence) com threads hospedadas e um inspetor — consulte o [quickstart de CrewAI do CopilotKit](https://docs.copilotkit.ai/crewai-crews/quickstart) se preferir isso. O código do frontend nesta seção é o mesmo de qualquer forma; apenas como o agente é hospedado e registrado é que muda.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
O CrewAI roda por trás do AG-UI em três formatos: **Flows** comuns (usados ao longo destes guias), **[Conversational Flows](/edge/en/guides/frontend/conversational-flows)** (nativos, cientes de sessão, baseados em turnos, com paridade total de recursos) e **Crews** (chat básico). O frontend nesta seção é idêntico entre eles — apenas a autoria e o registro no backend é que diferem.
|
||||
</Note>
|
||||
|
||||
## Guia de integração
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="Sirva seu agente por AG-UI">
|
||||
|
||||
Instale o pacote de integração no seu projeto CrewAI:
|
||||
|
||||
```bash
|
||||
pip install ag-ui-crewai
|
||||
```
|
||||
|
||||
Exponha o seu agente a partir de um app FastAPI. Flows usam `add_crewai_flow_fastapi_endpoint`; Crews usam `add_crewai_crew_fastapi_endpoint`. Você pode registrar quantos quiser, cada um em seu próprio path.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```python Flow
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_flow_fastapi_endpoint
|
||||
from my_agents.recipe_flow import RecipeFlow
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_flow_fastapi_endpoint(
|
||||
app=app,
|
||||
flow=RecipeFlow(),
|
||||
path="/recipe",
|
||||
)
|
||||
```
|
||||
|
||||
```python Crew
|
||||
# server.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_crewai.endpoint import add_crewai_crew_fastapi_endpoint
|
||||
from my_agents.research_crew import ResearchCrew
|
||||
|
||||
app = FastAPI(title="CrewAI Agent Server")
|
||||
|
||||
add_crewai_crew_fastapi_endpoint(
|
||||
app=app,
|
||||
crew=ResearchCrew().crew(),
|
||||
path="/research",
|
||||
)
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Execute:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000
|
||||
```
|
||||
|
||||
<Note>
|
||||
Defina as variáveis de ambiente do seu provedor de LLM (por exemplo `OPENAI_API_KEY`) antes de iniciar o servidor.
|
||||
</Note>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Crie um app Next.js">
|
||||
|
||||
Se você ainda não tem um frontend, gere um:
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest my-app
|
||||
cd my-app
|
||||
```
|
||||
|
||||
Instale o CopilotKit e o cliente AG-UI do CrewAI:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Adicione o runtime do CopilotKit">
|
||||
|
||||
Crie uma rota que registre o(s) seu(s) agente(s) CrewAI no runtime do CopilotKit. Cada agente aponta para um path no seu servidor Python via `CrewAIAgent`.
|
||||
|
||||
```ts
|
||||
// app/api/copilotkit/route.ts
|
||||
import {
|
||||
CopilotRuntime,
|
||||
InMemoryAgentRunner,
|
||||
createCopilotEndpoint,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
import { handle } from "hono/vercel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
recipe: new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
},
|
||||
runner: new InMemoryAgentRunner(),
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
const handler = handle(app);
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Envolva seu app com o provider">
|
||||
|
||||
Aponte `<CopilotKit>` para a rota do runtime e nomeie o agente que você registrou.
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="recipe">
|
||||
<YourApp />
|
||||
<CopilotSidebar agentId="recipe" labels={{ modalHeaderTitle: "Assistant" }} />
|
||||
</CopilotKit>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Execute">
|
||||
|
||||
Inicie os dois processos e abra o app. Conversar na sidebar agora executa o seu Crew ou Flow.
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1
|
||||
npm run dev # terminal 2
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Opções de UI de chat
|
||||
|
||||
O CopilotKit entrega três superfícies de chat intercambiáveis. Troque o componente; a fiação é idêntica.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx Sidebar
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotSidebar agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Popup
|
||||
import { CopilotPopup } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotPopup agentId="recipe" />
|
||||
```
|
||||
|
||||
```tsx Inline
|
||||
import { CopilotChat } from "@copilotkit/react-core/v2";
|
||||
|
||||
<CopilotChat agentId="recipe" />
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Para onde ir em seguida
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Generative UI" icon="wand-magic-sparkles" href="/edge/en/guides/frontend/generative-ui">
|
||||
Renderize chamadas de tool e o estado do agente como componentes personalizados.
|
||||
</Card>
|
||||
<Card title="Frontend Actions" icon="bolt" href="/edge/en/guides/frontend/frontend-actions">
|
||||
Permita que o agente chame funções que rodam no navegador.
|
||||
</Card>
|
||||
<Card title="Human-in-the-Loop" icon="user-check" href="/edge/en/guides/frontend/human-in-the-loop">
|
||||
Restrinja ações do agente por trás da aprovação do usuário.
|
||||
</Card>
|
||||
<Card title="Predictive State" icon="gauge-high" href="/edge/en/guides/frontend/predictive-state-updates">
|
||||
Transmita o estado em andamento para a UI enquanto o agente trabalha.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -140,7 +140,7 @@ Você pode se conectar a LLMs compatíveis com a OpenAI usando variáveis de amb
|
||||
# Exemplo usando a API compatível com OpenAI do Gemini.
|
||||
os.environ["OPENAI_API_KEY"] = "your-gemini-key" # Deve começar com AIza...
|
||||
os.environ["OPENAI_API_BASE"] = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-2.0-flash" # Adicione aqui seu modelo do Gemini, sob openai/
|
||||
os.environ["OPENAI_MODEL_NAME"] = "openai/gemini-3.7-flash" # Adicione aqui seu modelo do Gemini, sob openai/
|
||||
```
|
||||
</CodeGroup>
|
||||
</Tab>
|
||||
@@ -158,7 +158,7 @@ Você pode se conectar a LLMs compatíveis com a OpenAI usando variáveis de amb
|
||||
```python Google
|
||||
# Exemplo usando a API compatível com OpenAI do Gemini
|
||||
llm = LLM(
|
||||
model="openai/gemini-2.0-flash",
|
||||
model="openai/gemini-3.7-flash",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
api_key="your-gemini-key", # Deve começar com AIza...
|
||||
)
|
||||
|
||||
@@ -148,7 +148,7 @@ Agentes de planejamento se beneficiam de modelos de raciocínio para pensamento
|
||||
from crewai import Agent, Task, Crew, LLM
|
||||
|
||||
# Modelo de raciocínio para planejamento estratégico
|
||||
manager_llm = LLM(model="gemini-2.5-flash-preview-05-20", temperature=0.1)
|
||||
manager_llm = LLM(model="gemini/gemini-3.7-flash", temperature=0.1)
|
||||
|
||||
# Modelo criativo para gerar conteúdo
|
||||
content_llm = LLM(model="claude-3-5-sonnet-20241022", temperature=0.7)
|
||||
@@ -413,7 +413,7 @@ Em vez de repetir o framework estratégico, segue um checklist tático para impl
|
||||
# Agentes gerenciadores ou de coordenação
|
||||
manager_agent = Agent(
|
||||
role="Project Manager",
|
||||
llm=LLM(model="gemini-2.5-flash-preview-05-20"),
|
||||
llm=LLM(model="gemini/gemini-3.7-flash"),
|
||||
# ... demais configs
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ uso de ferramentas, chamadas de API, respostas, quaisquer dados processados pelo
|
||||
Quando o recurso `share_crew` está ativado, dados detalhados, incluindo descrições das tarefas, histórias ou objetivos dos agentes e outros atributos específicos são coletados
|
||||
para fornecer insights mais detalhados. Essa coleta expandida pode incluir informações pessoais caso o usuário as tenha inserido em seus crews ou tarefas.
|
||||
Usuários devem considerar cuidadosamente o conteúdo de seus crews e tarefas antes de habilitar o `share_crew`.
|
||||
A telemetria pode ser desabilitada ao definir a variável de ambiente `CREWAI_DISABLE_TELEMETRY` como `true` ou ao definir `OTEL_SDK_DISABLED` como `true` (observe que esta última desabilita toda instrumentação OpenTelemetry globalmente).
|
||||
A telemetria do CrewAI pode ser desabilitada ao definir `CREWAI_DISABLE_TELEMETRY` como `true`, `1`, `yes` ou `on` (qualquer capitalização). `OTEL_SDK_DISABLED` com os mesmos valores também desabilita o exportador do CrewAI. O SDK do OpenTelemetry em si ainda só reconhece `true` para desabilitar as demais instrumentações do processo.
|
||||
|
||||
### Exemplos:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (ou `yes` / `on`) funciona como `true`. Valores não reconhecidos são ignorados e a telemetria permanece ligada.
|
||||
|
||||
### Isolamento da sua própria configuração do OpenTelemetry
|
||||
|
||||
A telemetria do CrewAI roda em seu próprio `TracerProvider` privado e nunca se
|
||||
@@ -61,7 +63,7 @@ por meio do próprio tracer provider, que é independente do descrito aqui.
|
||||
| Sim | Atributos do LLM | Inclui: nome, model_name, model, top_k, temperatura e nome da classe do LLM. Todos técnicos, sem dados pessoais. |
|
||||
| Sim | Criação de Projeto pelo CLI do crewAI | Inclui: o fato de um novo projeto ter sido criado por `crewai create`, de qual tipo ele é (`crew`, `json_crew` ou `flow`) e o ID de projeto gerado para esse novo projeto e gravado no `pyproject.toml` dele. É o ID do próprio projeto novo, registrado separadamente do `project_id` do diretório de onde o comando foi executado — os dois podem diferir. Sem nome de projeto, sem conteúdo de arquivos, sem código. Sem dados pessoais. |
|
||||
| Sim | Tentativa de Deploy do Crew pelo CLI do crewAI | Inclui: O fato de um deploy estar sendo realizado e o crew id, se está tentando buscar logs, e se o deploy foi iniciado por um comando do CLI ou pela TUI de execução. Não inclui conteúdo do projeto ou do crew nem dados pessoais. |
|
||||
| Sim | Ambiente de Execução | Inclui: qual assistente de código com IA está executando o processo, se houver (um de uma lista fixa como `claude_code`, `codex`, `cursor` ou `unknown`), onde o processo é executado (um de uma lista fixa como `ci`, `container`, `serverless`, `interactive`) e o `project_id` do seu `pyproject.toml` quando houver um configurado. A detecção lê apenas se variáveis de ambiente conhecidas estão definidas, nunca seus valores. Sem dados pessoais. |
|
||||
| Sim | Ambiente de Execução | Inclui: qual assistente de código com IA está executando o processo, se houver (um valor de uma lista fixa como `claude_code`, `codex`, `cursor` ou `unknown`), onde o processo é executado (um valor de uma lista fixa como `ci`, `container`, `serverless`, `interactive`), o `project_id` do seu `pyproject.toml` quando houver um configurado e uma faixa aproximada de tamanho da máquina (uma de `1-2`, `3-4`, `5-8`, `9-16`, `17-32`, `33+` ou `unknown`). A faixa é um intervalo, nunca a contagem exata de núcleos — a contagem exata é opcional, em Informações de Ambiente abaixo. A faixa de tamanho vem da contagem de núcleos do host; a detecção do assistente e do local de execução lê apenas se variáveis de ambiente conhecidas estão definidas, nunca seus valores. Sem dados pessoais. |
|
||||
| Sim | Sinais de Ciclo de Vida do Flow | Inclui: que um flow iniciou, se foi concluído ou falhou, se um de seus métodos falhou, se pausou para entrada ou feedback humano, se o início foi uma execução retomada, se um turno de conversa falhou, quanto tempo o flow executou, e se o flow é um que a CrewAI executa internamente ou um que você escreveu. O nome do flow é registrado, como já é para criação e execução de flow. Quando um flow ou um de seus métodos falha, o **nome da classe** da exceção é registrado (por exemplo `TimeoutError`) para permitir o diagnóstico de falhas — nunca a mensagem de erro, que pode conter prompts, saída do modelo, caminhos de arquivo ou credenciais. Nomes de métodos e estado do flow nunca são registrados. Nenhum dado pessoal. |
|
||||
| Sim | Sinal de Compartilhamento de Trace | Inclui: que um lote de traces foi compartilhado com sucesso com o CrewAI AMP, e se foi compartilhado anonimamente (antes de você ter uma conta) ou vinculado à sua conta. Como todo span, também carrega os atributos de Ambiente de Execução descritos acima (`project_id` quando configurado, o assistente de programação e o runtime). Esta linha descreve apenas a telemetria do compartilhamento — não o conteúdo dos traces nem o acesso concedido por links de traces compartilhados. O conteúdo dos traces, entradas e saídas nunca são registrados neste sinal. Antes de compartilhar traces, revise segredos, dados pessoais e as configurações de redação e retenção do AMP. |
|
||||
| Não | Dados Expandidos do Agente | Inclui: descrição do objetivo, texto da história, identificador de arquivo i18n prompt. Usuários devem garantir que não haja info pessoal nesses campos de texto. |
|
||||
|
||||
@@ -50,17 +50,16 @@ Essas ferramentas se integram com serviços de IA e machine learning para aprimo
|
||||
- **Segurança em IA**: Implemente moderação de conteúdo e checagens de segurança
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
```
|
||||
|
||||
@@ -1,117 +1,148 @@
|
||||
---
|
||||
title: Channels
|
||||
description: Run the same CrewAI agent as a chat bot on Slack and Discord with the CopilotKit Channels SDK.
|
||||
icon: slack
|
||||
description: Run the same CrewAI agent as a Slack or Teams bot with the CopilotKit Channels SDK and managed Intelligence platform.
|
||||
icon: messages
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
## Meet your users where they already are
|
||||
|
||||
The CrewAI agent you built in the [Overview](/edge/en/guides/frontend/overview) does not have to live behind a web app. The same Crew or Flow can run as a bot inside a messaging platform. No rebuild, no second copy of your agent logic: the agent stays exposed over the [AG-UI protocol](https://docs.ag-ui.com), and a bot process drives it.
|
||||
The CrewAI agent you built in the [Overview](/edge/en/guides/frontend/overview) does not have to live behind a web app. The same Crew or Flow can run as a bot inside a messaging platform. No rebuild, no second copy of your agent logic: the agent stays exposed over the [AG-UI protocol](https://docs.ag-ui.com), and a **channel** drives it from Slack or Microsoft Teams.
|
||||
|
||||
CopilotKit's [Channels SDK](https://docs.copilotkit.ai/reference/channels) provides that bot process. It ships a platform-agnostic engine plus per-platform adapters.
|
||||
CopilotKit's [Channels SDK](https://docs.copilotkit.ai/slack) provides that channel. You declare a `createChannel` in a small runtime, point it at your CrewAI agent, and CopilotKit's managed **Intelligence** platform brokers the connection to the messaging provider.
|
||||
|
||||
<Note>
|
||||
Unlike the rest of this section, Channels is **not self-hosted**. It runs through **CopilotKit Intelligence** — a required surface for Channels, by design (a free tier is available). Intelligence holds the platform connection and credentials, receives each platform event, and delivers the turn to your channel process; your process runs the agent and streams the reply back. You configure Slack once in the Intelligence dashboard, and platform credentials never enter your process. Your agent, tools, and state stay yours.
|
||||
</Note>
|
||||
|
||||
## How it fits together
|
||||
|
||||
Nothing about your agent server changes. It keeps serving your Crew or Flow over AG-UI exactly as in the Overview. What you add is a separate **bot process**: it connects to a platform adapter, listens for messages, and runs your agent when it is messaged. The reply streams back into the channel.
|
||||
Nothing about your CrewAI agent server changes. It keeps serving your Crew or Flow over AG-UI exactly as in the Overview. What you add is a separate long-running Node process built with `@copilotkit/channels`: it registers a channel on the `CopilotRuntime`, connects to Intelligence, and runs your agent whenever a message arrives.
|
||||
|
||||
```
|
||||
Slack / Discord ──► Channels bot process ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
Slack / Teams ──► CopilotKit Intelligence ──► channel process (Node) ──► CrewAI server (AG-UI) ──► Crew / Flow
|
||||
```
|
||||
|
||||
Your agent server can keep serving the web frontend from the Overview at the same time. The web app and the bot are just two clients of one AG-UI endpoint.
|
||||
The channel process holds a persistent connection to the Intelligence gateway, so it needs a long-running host — a serverless request handler cannot own that connection. Your CrewAI server can keep serving the web frontend from the Overview at the same time: the web app and the channel are just two clients of one AG-UI endpoint.
|
||||
|
||||
## Slack
|
||||
## Integration guide
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step title="Install the Channels packages">
|
||||
|
||||
The Channels SDK is batteries-included — every platform ships in the one package, with no per-platform adapter to install. Add it alongside the runtime that hosts the channel and the CrewAI AG-UI client:
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/channels @copilotkit/channels-slack @ag-ui/crewai
|
||||
npm install @copilotkit/channels @copilotkit/runtime @ag-ui/crewai
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create a Slack app and get tokens">
|
||||
<Step title="Create a Channel in Intelligence">
|
||||
|
||||
Create an app in the Slack API dashboard for your workspace, enable Socket Mode, and grant it the message and event scopes it needs to read and post in channels. Then expose its tokens to the bot process:
|
||||
In the [CopilotKit dashboard](https://docs.copilotkit.ai/slack), create a Channel and connect Slack — Intelligence walks you through creating the Slack app and holds its credentials. That leaves two environment variables for your process, both from the dashboard:
|
||||
|
||||
```bash
|
||||
export SLACK_BOT_TOKEN=xoxb-... # bot user token
|
||||
export SLACK_APP_TOKEN=xapp-... # app-level token (Socket Mode)
|
||||
export INTELLIGENCE_API_KEY=... # authenticates the runtime with Intelligence (free tier available)
|
||||
export INTELLIGENCE_CHANNEL_ID=... # the Channel ID, matched by createChannel({ name })
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Point the bot at your CrewAI agent">
|
||||
<Step title="Define the channel">
|
||||
|
||||
`createBot` wires a Slack adapter to your agent. The `agent` factory returns a `CrewAIAgent` pointed at the AG-UI path your server exposes (the same URL you registered in the runtime in the Overview).
|
||||
`createChannel` declares the channel and attaches your agent. Build the agent as a per-thread factory so each conversation gets its own session, using the same `CrewAIAgent` the Overview uses in the web runtime, pointed at your AG-UI endpoint. `identifyUser: "platform"` lets Intelligence map each platform user to a stable identity.
|
||||
|
||||
```ts
|
||||
// bot.ts
|
||||
import { createBot } from "@copilotkit/channels";
|
||||
import { slack, defaultSlackTools, defaultSlackContext } from "@copilotkit/channels-slack";
|
||||
// channel.ts
|
||||
import { createChannel } from "@copilotkit/channels";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const bot = createBot({
|
||||
adapters: [
|
||||
slack({
|
||||
botToken: process.env.SLACK_BOT_TOKEN!, // xoxb-…
|
||||
appToken: process.env.SLACK_APP_TOKEN!, // xapp-… (Socket Mode)
|
||||
}),
|
||||
],
|
||||
agent: (threadId) => new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
tools: [...defaultSlackTools],
|
||||
context: [...defaultSlackContext],
|
||||
const channel = createChannel({
|
||||
name: process.env.INTELLIGENCE_CHANNEL_ID!, // must match the Channel ID in Intelligence
|
||||
identifyUser: "platform",
|
||||
// A fresh agent per conversation, pointed at your CrewAI AG-UI endpoint.
|
||||
agent: (threadId) => {
|
||||
const agent = new CrewAIAgent({ url: "http://localhost:8000/recipe" });
|
||||
agent.threadId = threadId;
|
||||
return agent;
|
||||
},
|
||||
});
|
||||
|
||||
bot.start();
|
||||
// A mention subscribes the thread and runs the agent; afterwards every message
|
||||
// in a subscribed thread runs it without needing another mention.
|
||||
channel.onMention(async ({ thread }) => {
|
||||
await thread.subscribe();
|
||||
await thread.runAgent();
|
||||
});
|
||||
|
||||
channel.onMessage(async ({ thread }) => {
|
||||
if (await thread.isSubscribed()) await thread.runAgent();
|
||||
});
|
||||
|
||||
export { channel };
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Run the bot">
|
||||
<Step title="Register the channel on the runtime">
|
||||
|
||||
Start the bot process alongside your agent server:
|
||||
Create a `CopilotRuntime` with the Intelligence gateway and your channel, then serve it with `createCopilotNodeListener`. The `agents` map stays empty — the channel supplies its own agent. Wait for the channel to be ready so a broken config fails startup loudly.
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
import { createServer } from "node:http";
|
||||
import { CopilotRuntime, CopilotKitIntelligence } from "@copilotkit/runtime/v2";
|
||||
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
|
||||
import { channel } from "./channel";
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {}, // the channel supplies its own agent; no web-facing agents needed
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY!, // free tier available
|
||||
}),
|
||||
channels: [channel],
|
||||
});
|
||||
|
||||
const listener = createCopilotNodeListener({ runtime });
|
||||
await listener.channels?.ready({ timeoutMs: 15_000 });
|
||||
|
||||
createServer(listener).listen(3123, () => {
|
||||
console.log("Channels runtime listening on port 3123");
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Run the channel runtime">
|
||||
|
||||
Start it alongside your CrewAI agent server:
|
||||
|
||||
```bash
|
||||
uvicorn server:app --port 8000 # terminal 1 — CrewAI agent server
|
||||
node bot.ts # terminal 2 — Slack bot
|
||||
npx tsx server.ts # terminal 2 — Channels runtime
|
||||
```
|
||||
|
||||
Message the bot in Slack and it runs your Crew or Flow, streaming the reply back into the thread.
|
||||
Mention the bot in Slack or Teams and it runs your Crew or Flow, streaming the reply back into the thread. The thread stays subscribed, so follow-up messages run without another mention.
|
||||
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
Slack app scopes, Socket Mode setup, and the full adapter options are maintained by CopilotKit. Follow the [Slack channel reference](https://docs.copilotkit.ai/reference/channels/slack) together with Slack's own app setup guide for the authoritative steps.
|
||||
</Note>
|
||||
## The event model
|
||||
|
||||
## Discord
|
||||
A channel reacts to platform events with handlers, and each handler receives a `thread` you drive with a few methods:
|
||||
|
||||
Discord uses the same `createBot` engine with the Discord adapter from `@copilotkit/channels-discord`:
|
||||
- **`channel.onMention`** fires when a user @-mentions the bot. Call `thread.subscribe()` to join the thread, then `thread.runAgent()` to run your CrewAI agent on the mention.
|
||||
- **`channel.onMessage`** fires on every message in a thread the bot can see. Gate it with `thread.isSubscribed()` so the agent only responds where it has joined, then `thread.runAgent()`.
|
||||
- **`thread.runAgent()`** runs the attached CrewAI agent for the current turn and streams its output back into the channel. Pass `{ prompt }` to override the text the agent runs on.
|
||||
|
||||
```ts
|
||||
import { createBot } from "@copilotkit/channels";
|
||||
import { discord } from "@copilotkit/channels-discord";
|
||||
import { CrewAIAgent } from "@ag-ui/crewai";
|
||||
|
||||
const bot = createBot({
|
||||
adapters: [discord({ token: process.env.DISCORD_BOT_TOKEN! })],
|
||||
agent: (threadId) => new CrewAIAgent({ url: "http://localhost:8000/recipe" }),
|
||||
});
|
||||
|
||||
bot.start();
|
||||
```
|
||||
|
||||
See the [Discord channel reference](https://docs.copilotkit.ai/reference/channels/discord) for the exact adapter options and bot setup.
|
||||
Your agent receives an ordinary AG-UI `RunAgentInput` and emits ordinary AG-UI events; the platform mechanics stay behind the channel, so the same Crew or Flow runs unchanged across every platform. The channel also exposes handlers for welcomes, interrupts, commands, reactions, and modals — see the [`Channel` reference](https://docs.copilotkit.ai/reference/channels/classes/Channel) for the full surface.
|
||||
|
||||
## Platform support
|
||||
|
||||
Slack and Discord have official Channels adapters (`@copilotkit/channels-slack`, `@copilotkit/channels-discord`). Microsoft Teams is available through CopilotKit's managed offering (currently waitlisted). Check the [Channels reference](https://docs.copilotkit.ai/reference/channels) for the current list before promising a platform.
|
||||
The managed Intelligence path covers **Slack** and **Microsoft Teams** today — the same channel code runs on either, and `message.platform` / `thread.platform` report the native origin. Other messaging platforms connect the same managed way, with your channel code unchanged. Check the [CopilotKit Channels documentation](https://docs.copilotkit.ai/slack) for the current platform list and per-platform setup.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ The two connect through the [AG-UI protocol](https://docs.ag-ui.com). The `ag-ui
|
||||
<Card title="Shared State" icon="arrows-rotate" href="/edge/en/guides/frontend/shared-state">
|
||||
Keep agent state and your app UI in two-way sync.
|
||||
</Card>
|
||||
<Card title="Channels" icon="slack" href="/edge/en/guides/frontend/channels">
|
||||
<Card title="Channels" icon="messages" href="/edge/en/guides/frontend/channels">
|
||||
Run the same agent as a Slack, Discord, or Teams bot.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -38,11 +38,6 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
find_json_project_file,
|
||||
validate_crew_project,
|
||||
)
|
||||
from crewai_core.project import (
|
||||
ProjectDefinitionError,
|
||||
configured_project_definition,
|
||||
@@ -52,6 +47,8 @@ from crewai_core.project import (
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
from crewai_cli.utils import normalize_package_name
|
||||
|
||||
|
||||
console = Console()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,15 +105,143 @@ _KNOWN_API_KEY_HINTS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def normalize_package_name(project_name: str) -> str:
|
||||
"""Normalize a pyproject project.name into a Python package directory name.
|
||||
_JSON_VALIDATION_MARKER = "CREWAI_JSON_VALIDATION_RESULT="
|
||||
_JSON_VALIDATOR_SCRIPT = f"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
Mirrors the rules in ``crewai.cli.create_crew.create_crew`` so the
|
||||
validator agrees with the scaffolder about where ``src/<pkg>/`` should
|
||||
live.
|
||||
"""
|
||||
folder = project_name.replace(" ", "_").replace("-", "_").lower()
|
||||
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
|
||||
try:
|
||||
from crewai.project.json_loader import validate_crew_project
|
||||
project = validate_crew_project(sys.argv[1], agents_dir=sys.argv[2])
|
||||
payload = {{"ok": True, "agent_names": project.agent_names}}
|
||||
except BaseException as exc:
|
||||
errors = getattr(exc, "errors", None)
|
||||
payload = {{
|
||||
"ok": False,
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc),
|
||||
"errors": errors if isinstance(errors, list) else None,
|
||||
}}
|
||||
|
||||
print({_JSON_VALIDATION_MARKER!r} + json.dumps(payload))
|
||||
""".strip()
|
||||
|
||||
|
||||
class _JSONProjectValidationError(ValueError):
|
||||
def __init__(self, errors: list[str]) -> None:
|
||||
self.errors = errors
|
||||
super().__init__("\n".join(errors))
|
||||
|
||||
|
||||
class _JSONProjectEnvironmentError(RuntimeError):
|
||||
"""JSON validation could not run in the project's environment."""
|
||||
|
||||
hint = (
|
||||
"Install `uv` if needed, run `uv sync` in the project directory, "
|
||||
"then retry with `uv run crewai deploy validate`."
|
||||
)
|
||||
|
||||
|
||||
def _find_json_project_file(directory: Path, stem: str) -> Path | None:
|
||||
for extension in (".jsonc", ".json"):
|
||||
candidate = directory / f"{stem}{extension}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _validate_json_project_in_project_env(
|
||||
crew_path: Path, agents_dir: Path, project_root: Path
|
||||
) -> list[str]:
|
||||
"""Validate a JSON crew with the full CrewAI package from its project env."""
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path is None:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"The `uv` executable is required to validate JSON crews from a "
|
||||
"standalone CLI installation."
|
||||
)
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603 - fixed command plus trusted paths
|
||||
[
|
||||
uv_path,
|
||||
"run",
|
||||
"python",
|
||||
"-c",
|
||||
_JSON_VALIDATOR_SCRIPT,
|
||||
str(crew_path),
|
||||
str(agents_dir),
|
||||
],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"JSON crew validation timed out after 120s."
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
f"Could not start JSON crew validation: {exc}"
|
||||
) from exc
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
for line in reversed(proc.stdout.splitlines()):
|
||||
if not line.startswith(_JSON_VALIDATION_MARKER):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line.removeprefix(_JSON_VALIDATION_MARKER))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
|
||||
if payload is None:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()
|
||||
raise _JSONProjectEnvironmentError(
|
||||
detail or "JSON crew validation produced no result."
|
||||
)
|
||||
|
||||
if not payload.get("ok"):
|
||||
errors = payload.get("errors")
|
||||
if isinstance(errors, list) and all(isinstance(error, str) for error in errors):
|
||||
raise _JSONProjectValidationError(errors)
|
||||
error_type = payload.get("error_type", "Error")
|
||||
error = payload.get("error", "JSON crew validation failed")
|
||||
raise _JSONProjectEnvironmentError(f"{error_type}: {error}")
|
||||
|
||||
agent_names = payload.get("agent_names")
|
||||
if not isinstance(agent_names, list) or not all(
|
||||
isinstance(name, str) for name in agent_names
|
||||
):
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"JSON crew validation returned invalid agent names."
|
||||
)
|
||||
return agent_names
|
||||
|
||||
|
||||
def _validate_json_project(
|
||||
crew_path: Path, agents_dir: Path, project_root: Path
|
||||
) -> list[str]:
|
||||
"""Validate locally when possible, otherwise use the project's environment."""
|
||||
try:
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
validate_crew_project,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name and (exc.name == "crewai" or exc.name.startswith("crewai.")):
|
||||
return _validate_json_project_in_project_env(
|
||||
crew_path, agents_dir, project_root
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
project = validate_crew_project(crew_path, agents_dir)
|
||||
except JSONProjectValidationError as exc:
|
||||
raise _JSONProjectValidationError(exc.errors) from exc
|
||||
return project.agent_names
|
||||
|
||||
|
||||
class DeployValidator:
|
||||
@@ -232,11 +357,13 @@ class DeployValidator:
|
||||
agents_dir = crew_path.parent / "agents"
|
||||
agents_dir_ok = self._check_json_agents_dir(agents_dir)
|
||||
|
||||
project = None
|
||||
agent_names: list[str] | None = None
|
||||
try:
|
||||
if agents_dir_ok:
|
||||
project = validate_crew_project(crew_path, agents_dir)
|
||||
except JSONProjectValidationError as e:
|
||||
agent_names = _validate_json_project(
|
||||
crew_path, agents_dir, self.project_root
|
||||
)
|
||||
except _JSONProjectValidationError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"invalid_crew_json",
|
||||
@@ -245,6 +372,15 @@ class DeployValidator:
|
||||
hint="Fix the JSON crew, agent, and task references before deploying.",
|
||||
)
|
||||
return self.results
|
||||
except _JSONProjectEnvironmentError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"json_validation_environment_failed",
|
||||
"Could not validate the JSON crew in the project environment",
|
||||
detail=str(e),
|
||||
hint=e.hint,
|
||||
)
|
||||
return self.results
|
||||
except Exception as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
@@ -254,8 +390,8 @@ class DeployValidator:
|
||||
)
|
||||
return self.results
|
||||
|
||||
if project is not None:
|
||||
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
||||
if agent_names is not None:
|
||||
self._check_env_vars_json(crew_path, agents_dir, agent_names)
|
||||
self._check_version_vs_lockfile()
|
||||
|
||||
return self.results
|
||||
@@ -288,7 +424,7 @@ class DeployValidator:
|
||||
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
|
||||
|
||||
for name in agent_names:
|
||||
agent_path = find_json_project_file(agents_dir, name)
|
||||
agent_path = _find_json_project_file(agents_dir, name)
|
||||
if agent_path is None:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -4,8 +4,7 @@ import subprocess
|
||||
import click
|
||||
from crewai_core.project import configured_project_definition, read_toml
|
||||
|
||||
from crewai_cli.deploy.validate import normalize_package_name
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials, normalize_package_name
|
||||
|
||||
|
||||
def _is_json_crew_project(project_root: Path | None = None) -> bool:
|
||||
|
||||
@@ -39,6 +39,7 @@ __all__ = [
|
||||
"get_project_version",
|
||||
"is_dmn_mode_enabled",
|
||||
"load_env_vars",
|
||||
"normalize_package_name",
|
||||
"parse_toml",
|
||||
"read_toml",
|
||||
"render_template",
|
||||
@@ -67,6 +68,12 @@ console = Console()
|
||||
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
|
||||
|
||||
|
||||
def normalize_package_name(project_name: str) -> str:
|
||||
"""Normalize a project name into its scaffolded Python package name."""
|
||||
folder = project_name.replace(" ", "_").replace("-", "_").lower()
|
||||
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
|
||||
|
||||
|
||||
def is_dmn_mode_enabled() -> bool:
|
||||
"""Return True when the enterprise non-interactive mode is enabled."""
|
||||
value = os.environ.get("CREWAI_DMN")
|
||||
|
||||
148
lib/cli/tests/deploy/test_cli_only_install.py
Normal file
148
lib/cli/tests/deploy/test_cli_only_install.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Regression coverage for crewai-cli installed without the full crewai package."""
|
||||
|
||||
import builtins
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import crewai_cli.deploy.validate as validate_module
|
||||
|
||||
|
||||
def test_reported_commands_run_without_crewai() -> None:
|
||||
script = r"""
|
||||
import builtins
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_without_crewai(name, *args, **kwargs):
|
||||
if name == "crewai" or name.startswith("crewai."):
|
||||
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
builtins.__import__ = import_without_crewai
|
||||
|
||||
from crewai_cli.cli import crewai
|
||||
import crewai_cli.command as command_module
|
||||
import crewai_cli.install_crew as install_module
|
||||
|
||||
def not_logged_in():
|
||||
raise RuntimeError("not logged in")
|
||||
|
||||
command_module.get_auth_token = not_logged_in
|
||||
install_module.build_env_with_all_tool_credentials = lambda: {}
|
||||
install_module.subprocess.run = lambda *args, **kwargs: subprocess.CompletedProcess(
|
||||
args[0], 0
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
Path("pyproject.toml").write_text('[project]\nname = "demo"\n')
|
||||
|
||||
deploy_result = runner.invoke(crewai, ["deploy", "list"])
|
||||
assert deploy_result.exit_code == 0, deploy_result.output
|
||||
assert "Please sign up/login" in deploy_result.output
|
||||
assert not isinstance(deploy_result.exception, ModuleNotFoundError)
|
||||
|
||||
install_result = runner.invoke(crewai, ["install"])
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
assert not isinstance(install_result.exception, ModuleNotFoundError)
|
||||
"""
|
||||
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_json_validation_uses_project_environment_without_crewai(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_without_crewai(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "crewai" or name.startswith("crewai."):
|
||||
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_run(
|
||||
command: list[str], **kwargs: Any
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
payload = {"ok": True, "agent_names": ["researcher"]}
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"uv output\n"
|
||||
f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", import_without_crewai)
|
||||
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
crew_path = tmp_path / "crew.jsonc"
|
||||
agents_dir = tmp_path / "agents"
|
||||
assert validate_module._validate_json_project(
|
||||
crew_path, agents_dir, tmp_path
|
||||
) == ["researcher"]
|
||||
|
||||
assert captured["command"][:4] == ["/usr/bin/uv", "run", "python", "-c"]
|
||||
assert captured["kwargs"]["cwd"] == tmp_path
|
||||
|
||||
|
||||
def test_project_environment_preserves_json_validation_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
payload = {"ok": False, "errors": ["tasks[0] references missing_agent"]}
|
||||
proc = subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n",
|
||||
stderr="",
|
||||
)
|
||||
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
|
||||
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: proc)
|
||||
|
||||
with pytest.raises(validate_module._JSONProjectValidationError) as exc_info:
|
||||
validate_module._validate_json_project_in_project_env(
|
||||
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
|
||||
)
|
||||
|
||||
assert exc_info.value.errors == ["tasks[0] references missing_agent"]
|
||||
|
||||
|
||||
def test_missing_uv_has_an_actionable_project_environment_error(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(shutil, "which", lambda command: None)
|
||||
|
||||
with pytest.raises(validate_module._JSONProjectEnvironmentError) as exc_info:
|
||||
validate_module._validate_json_project_in_project_env(
|
||||
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
|
||||
)
|
||||
|
||||
assert "required" in str(exc_info.value)
|
||||
assert "Install `uv`" in exc_info.value.hint
|
||||
assert "uv sync" in exc_info.value.hint
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
from crewai_cli.deploy.validate import (
|
||||
DeployValidator,
|
||||
Severity,
|
||||
_JSONProjectEnvironmentError,
|
||||
normalize_package_name,
|
||||
)
|
||||
|
||||
@@ -205,6 +206,33 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
|
||||
assert "runtime-only" in finding.detail
|
||||
|
||||
|
||||
def test_json_project_environment_failure_has_actionable_hint(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
|
||||
def fail_in_project_environment(*args: object) -> list[str]:
|
||||
raise _JSONProjectEnvironmentError("uv run failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.deploy.validate._validate_json_project",
|
||||
fail_in_project_environment,
|
||||
)
|
||||
|
||||
validator = DeployValidator(project_root=tmp_path)
|
||||
validator.run()
|
||||
|
||||
finding = next(
|
||||
result
|
||||
for result in validator.results
|
||||
if result.code == "json_validation_environment_failed"
|
||||
)
|
||||
assert finding.title == "Could not validate the JSON crew in the project environment"
|
||||
assert finding.detail == "uv run failed"
|
||||
assert "Install `uv`" in finding.hint
|
||||
assert "uv sync" in finding.hint
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -188,6 +188,23 @@ KNOWN_RUNTIME_CONTEXTS: Final[frozenset[str]] = frozenset(
|
||||
+ ["interactive", "non_interactive", _UNKNOWN]
|
||||
)
|
||||
|
||||
# Core counts as coarse bands, never the exact number. Powers of two, top band
|
||||
# open-ended: the observed maximum in the fleet is 512, and a span reporting 512
|
||||
# identifies one machine.
|
||||
_CPU_BANDS: Final[tuple[tuple[int, str], ...]] = (
|
||||
(2, "1-2"),
|
||||
(4, "3-4"),
|
||||
(8, "5-8"),
|
||||
(16, "9-16"),
|
||||
(32, "17-32"),
|
||||
)
|
||||
_CPU_BAND_ABOVE: Final[str] = "33+"
|
||||
|
||||
# The same guarantee again for detect_cpu_band().
|
||||
KNOWN_CPU_BANDS: Final[frozenset[str]] = frozenset(
|
||||
[band for _, band in _CPU_BANDS] + [_CPU_BAND_ABOVE, _UNKNOWN]
|
||||
)
|
||||
|
||||
|
||||
def detect_coding_agent() -> str:
|
||||
"""Best-effort detection of the AI coding assistant running this process.
|
||||
@@ -266,3 +283,30 @@ def detect_runtime_context() -> str:
|
||||
return "interactive" if sys.stdout.isatty() else "non_interactive"
|
||||
except (AttributeError, ValueError, OSError):
|
||||
return _UNKNOWN
|
||||
|
||||
|
||||
def detect_cpu_band() -> str:
|
||||
"""The machine's core count as a coarse band, never the exact number.
|
||||
|
||||
Answers "is this a server or someone's laptop", which ``runtime_context``
|
||||
cannot: its largest bucket is a catch-all, and ``non_interactive`` is
|
||||
returned identically by a gunicorn worker on a VM and by
|
||||
``python main.py > out.log`` on a MacBook. A band adds the capacity axis
|
||||
without the fingerprint an exact count would carry.
|
||||
|
||||
**Reports HOST cores, not the cgroup quota.** A 1-vCPU pod on a 96-core node
|
||||
lands in ``33+``. That is the right answer for "what kind of machine is this"
|
||||
and the wrong one for "what resources did this run get" --
|
||||
``os.process_cpu_count()`` gives the latter but needs Python 3.13, above this
|
||||
package's floor.
|
||||
|
||||
Returns:
|
||||
One of ``KNOWN_CPU_BANDS``; ``"unknown"`` when the count is unavailable.
|
||||
"""
|
||||
count = os.cpu_count()
|
||||
if not count or count < 1:
|
||||
return _UNKNOWN
|
||||
for upper, band in _CPU_BANDS:
|
||||
if count <= upper:
|
||||
return band
|
||||
return _CPU_BAND_ABOVE
|
||||
|
||||
@@ -32,7 +32,11 @@ from opentelemetry.trace import Span, Status, StatusCode
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai_core.project import get_project_id
|
||||
from crewai_core.runtime_env import detect_coding_agent, detect_runtime_context
|
||||
from crewai_core.runtime_env import (
|
||||
detect_coding_agent,
|
||||
detect_cpu_band,
|
||||
detect_runtime_context,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -144,6 +148,7 @@ def common_span_attributes() -> dict[str, str]:
|
||||
attributes = {
|
||||
"coding_agent": detect_coding_agent(),
|
||||
"runtime_context": detect_runtime_context(),
|
||||
"cpu_band": detect_cpu_band(),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -176,6 +181,9 @@ class Telemetry:
|
||||
|
||||
_instance: ClassVar[Self | None] = None
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
_TRUTHY_ENV: ClassVar[frozenset[str]] = frozenset({"1", "on", "true", "yes"})
|
||||
_FALSY_ENV: ClassVar[frozenset[str]] = frozenset({"", "0", "false", "no", "off"})
|
||||
_warned_env_flags: ClassVar[set[tuple[str, str]]] = set()
|
||||
|
||||
def __new__(cls) -> Self:
|
||||
if cls._instance is None:
|
||||
@@ -228,12 +236,39 @@ class Telemetry:
|
||||
raise
|
||||
self.ready = False
|
||||
|
||||
@classmethod
|
||||
def _env_flag_enabled(cls, name: str, *, default: bool = False) -> bool:
|
||||
"""Return whether ``name`` is a conventional yes-value.
|
||||
|
||||
Yes: ``true``, ``1``, ``yes``, ``on``. No: unset, ``false``, ``0``,
|
||||
``no``, ``off``, empty. Anything else is treated as unset and logged
|
||||
once per ``(name, raw)`` pair for the process.
|
||||
"""
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
value = raw.strip().lower()
|
||||
if value in cls._TRUTHY_ENV:
|
||||
return True
|
||||
if value in cls._FALSY_ENV:
|
||||
return False
|
||||
warning_key = (name, raw)
|
||||
if warning_key not in cls._warned_env_flags:
|
||||
cls._warned_env_flags.add(warning_key)
|
||||
logger.warning(
|
||||
"Unrecognized value %r for %s; expected true/1/yes/on or "
|
||||
"false/0/no/off. Treating as unset.",
|
||||
raw,
|
||||
name,
|
||||
)
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def _is_telemetry_disabled(cls) -> bool:
|
||||
return (
|
||||
os.getenv("OTEL_SDK_DISABLED", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TELEMETRY", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TRACKING", "false").lower() == "true"
|
||||
cls._env_flag_enabled("OTEL_SDK_DISABLED")
|
||||
or cls._env_flag_enabled("CREWAI_DISABLE_TELEMETRY")
|
||||
or cls._env_flag_enabled("CREWAI_DISABLE_TRACKING")
|
||||
)
|
||||
|
||||
def _should_execute_telemetry(self) -> bool:
|
||||
|
||||
@@ -18,8 +18,10 @@ from collections.abc import Iterator
|
||||
from crewai_core.runtime_env import (
|
||||
CODING_AGENT_ENV_MARKERS,
|
||||
GENERIC_AGENT_ENV_VARS,
|
||||
KNOWN_CPU_BANDS,
|
||||
RUNTIME_CONTEXT_ENV_MARKERS,
|
||||
detect_coding_agent,
|
||||
detect_cpu_band,
|
||||
detect_runtime_context,
|
||||
)
|
||||
from crewai_core.telemetry import Telemetry, common_span_attributes
|
||||
@@ -135,3 +137,74 @@ def test_cli_spans_carry_the_process_context(
|
||||
assert attributes is not None
|
||||
assert attributes["coding_agent"] == "claude_code"
|
||||
assert attributes["runtime_context"] == "ci"
|
||||
|
||||
|
||||
class TestCpuBand:
|
||||
"""A coarse capacity signal: enough to tell a server from a laptop, no more."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cores", "expected"),
|
||||
[
|
||||
(1, "1-2"),
|
||||
(2, "1-2"),
|
||||
(3, "3-4"),
|
||||
(4, "3-4"),
|
||||
(5, "5-8"),
|
||||
(8, "5-8"),
|
||||
(9, "9-16"),
|
||||
(16, "9-16"),
|
||||
(17, "17-32"),
|
||||
(32, "17-32"),
|
||||
(33, "33+"),
|
||||
(96, "33+"),
|
||||
(512, "33+"),
|
||||
],
|
||||
)
|
||||
def test_bands_are_inclusive_at_every_boundary(
|
||||
self, cores: int, expected: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: cores)
|
||||
assert detect_cpu_band() == expected
|
||||
|
||||
def test_the_largest_band_is_open_ended(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The exact count is the fingerprint: 512 cores identifies one machine.
|
||||
|
||||
The observed fleet maximum is 512, so the top band must absorb it rather
|
||||
than the value reaching a span.
|
||||
"""
|
||||
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: 512)
|
||||
band = detect_cpu_band()
|
||||
assert band == "33+"
|
||||
assert "512" not in band
|
||||
|
||||
@pytest.mark.parametrize("unavailable", [None, 0])
|
||||
def test_an_unavailable_count_is_unknown_not_a_band(
|
||||
self, unavailable: int | None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""`os.cpu_count()` returns None when it cannot tell; that is not "1-2"."""
|
||||
monkeypatch.setattr("crewai_core.runtime_env.os.cpu_count", lambda: unavailable)
|
||||
assert detect_cpu_band() == "unknown"
|
||||
|
||||
def test_the_real_machine_lands_in_the_closed_vocabulary(self) -> None:
|
||||
"""Unmocked: whatever this host reports must still be a known literal."""
|
||||
assert detect_cpu_band() in KNOWN_CPU_BANDS
|
||||
|
||||
def test_no_band_can_carry_a_precise_core_count(self) -> None:
|
||||
"""Every emittable value is a short opaque label, as for the sibling signals."""
|
||||
for band in KNOWN_CPU_BANDS:
|
||||
assert len(band) <= 32, band
|
||||
assert band.replace("-", "").replace("+", "").replace("_", "").isalnum(), (
|
||||
band
|
||||
)
|
||||
|
||||
def test_it_rides_every_span_rather_than_only_crew_created(self) -> None:
|
||||
"""The gated `cpus` sits on Crew Created alone, which answers nothing for
|
||||
Flow-only, CLI-only or standalone-agent runs. This one is a common
|
||||
attribute, so it is on all of them."""
|
||||
common_span_attributes.cache_clear()
|
||||
try:
|
||||
assert common_span_attributes()["cpu_band"] in KNOWN_CPU_BANDS
|
||||
finally:
|
||||
common_span_attributes.cache_clear()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
@@ -175,6 +176,48 @@ def test_configured_project_definition_rejects_empty_definition(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["true", "TRUE", "1", "yes", "on", " yes "])
|
||||
def test_core_telemetry_disabled_by_conventional_yes_values(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", value)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
assert Telemetry._is_telemetry_disabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "0", "no", "off", ""])
|
||||
def test_core_telemetry_stays_enabled_for_conventional_no_values(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", value)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
assert Telemetry._is_telemetry_disabled() is False
|
||||
|
||||
|
||||
def test_core_telemetry_unrecognized_disable_value_warns_once(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", "maybe")
|
||||
Telemetry._warned_env_flags.clear()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="crewai_core.telemetry"):
|
||||
assert Telemetry._env_flag_enabled("CREWAI_DISABLE_TELEMETRY") is False
|
||||
assert Telemetry._env_flag_enabled("CREWAI_DISABLE_TELEMETRY") is False
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
assert "CREWAI_DISABLE_TELEMETRY" in warnings[0].getMessage()
|
||||
assert "maybe" in warnings[0].getMessage()
|
||||
|
||||
|
||||
def test_core_telemetry_never_installs_a_global_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"Pillow~=12.3.0",
|
||||
"pypdf~=6.14.2",
|
||||
"pypdf~=6.16.1",
|
||||
"python-magic>=0.4.27",
|
||||
"aiocache~=0.12.3",
|
||||
"aiofiles~=24.1.0",
|
||||
@@ -19,8 +19,6 @@ dependencies = [
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# pypdf 6.14.2 is a security fix newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -107,12 +107,11 @@ stagehand = [
|
||||
"stagehand>=0.4.1",
|
||||
]
|
||||
github = [
|
||||
# <3.1.58 has GHSA-p538-c434-8v24 (arbitrary file truncation),
|
||||
# GHSA-3f7w-8rr8-f37f (unguarded git option forwarding),
|
||||
# GHSA-9rj7-rf2p-w77r, GHSA-4gmw-gg2m-w46p, GHSA-hh9p-6wh2-4mfc,
|
||||
# GHSA-wvpp-8hx9-p66j and GHSA-jm78-9fvv-mhgr (further unguarded git
|
||||
# option forwarding / arbitrary file read); force 3.1.58+.
|
||||
"gitpython>=3.1.58,<4",
|
||||
# <3.1.59 has PYSEC-2026-3785/GHSA-7833-fr7j-v32q,
|
||||
# PYSEC-2026-3786/GHSA-284h-m62q-gf8w, PYSEC-2026-3787/GHSA-8mcc-hrx5-hvxc,
|
||||
# and PYSEC-2026-3788/GHSA-5xxx-qhh7-9287. 3.1.60 hardens config escapes,
|
||||
# diff/actor parsing, and filesystem diffs; force 3.1.60+.
|
||||
"gitpython>=3.1.60,<4",
|
||||
"PyGithub==1.59.1",
|
||||
]
|
||||
rag = [
|
||||
@@ -121,11 +120,13 @@ rag = [
|
||||
]
|
||||
xml = [
|
||||
"unstructured[local-inference, all-docs]>=0.17.2",
|
||||
# unstructured allows nltk>=3.9.2, but <3.10.0 has GHSA-qvv7-cg9c-w4x3
|
||||
# (DNS-rebinding SSRF bypass), GHSA-fg7f-2386-8897 (ReDoS) and
|
||||
# GHSA-xh95-f55m-82fw (path traversal). Declared here, not only as a uv
|
||||
# override, so consumers installing crewai-tools[xml] get the fixed version.
|
||||
"nltk>=3.10.0",
|
||||
# unstructured allows nltk>=3.9.2, but <3.10.3 still has PYSEC-2026-3726
|
||||
# (symlink file read in IPIPANCorpusReader; 3.10.0-3.10.1) plus later
|
||||
# 3.10.2 findings. 3.10.3 still has unpatched GHSA-8mgp-746c-j5xp
|
||||
# (ignored in pip-audit until a release ships). TODO: drop that ignore
|
||||
# when bumping nltk past 3.10.3. Declared here, not only as a uv
|
||||
# override, so consumers installing crewai-tools[xml] get this floor.
|
||||
"nltk>=3.10.3",
|
||||
]
|
||||
oxylabs = [
|
||||
"oxylabs==2.0.0"
|
||||
|
||||
@@ -7,9 +7,6 @@ through the CrewAI platform API.
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
|
||||
CrewaiPlatformToolBuilder,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
|
||||
CrewaiPlatformTools,
|
||||
)
|
||||
@@ -17,6 +14,5 @@ from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
|
||||
|
||||
__all__ = [
|
||||
"CrewAIPlatformActionTool",
|
||||
"CrewaiPlatformToolBuilder",
|
||||
"CrewaiPlatformTools",
|
||||
]
|
||||
|
||||
@@ -1,108 +1,76 @@
|
||||
"""Crewai Enterprise Tools."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from crewai.tools.tool_failure import ToolFailure
|
||||
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
|
||||
from pydantic import Field, create_model
|
||||
import requests
|
||||
from pydantic import Field, PrivateAttr, create_model
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_api_base_url,
|
||||
get_platform_integration_token,
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
IntegrationsClient,
|
||||
LegacyClient,
|
||||
ToolExecutionFailure,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
class CrewAIPlatformActionTool(BaseTool):
|
||||
_client: IntegrationsClient = PrivateAttr()
|
||||
_tool_info: ToolInfo = PrivateAttr()
|
||||
app: str = Field(description="The integration slug for this action")
|
||||
action_name: str = Field(default="", description="The name of the action")
|
||||
action_schema: dict[str, Any] = Field(
|
||||
default_factory=dict, description="The schema of the action"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: str,
|
||||
app: str,
|
||||
action_name: str,
|
||||
action_schema: dict[str, Any],
|
||||
):
|
||||
parameters = action_schema.get("function", {}).get("parameters", {})
|
||||
tool_info: ToolInfo,
|
||||
client: IntegrationsClient | None = None,
|
||||
) -> None:
|
||||
schema_name = f"{tool_info.qualified_name}Schema"
|
||||
parameters = tool_info.parameters
|
||||
|
||||
if parameters and parameters.get("properties"):
|
||||
try:
|
||||
if "title" not in parameters:
|
||||
parameters = {**parameters, "title": f"{action_name}Schema"}
|
||||
parameters = {**parameters, "title": schema_name}
|
||||
if "type" not in parameters:
|
||||
parameters = {**parameters, "type": "object"}
|
||||
args_schema = create_model_from_schema(parameters)
|
||||
except Exception:
|
||||
args_schema = create_model(f"{action_name}Schema")
|
||||
args_schema = create_model(schema_name)
|
||||
else:
|
||||
args_schema = create_model(f"{action_name}Schema")
|
||||
args_schema = create_model(schema_name)
|
||||
|
||||
super().__init__(
|
||||
name=action_name.lower().replace(" ", "_"),
|
||||
description=description,
|
||||
name=tool_info.qualified_name,
|
||||
description=tool_info.description,
|
||||
args_schema=args_schema,
|
||||
app=app,
|
||||
app=tool_info.app,
|
||||
)
|
||||
self.action_name = action_name
|
||||
self.action_schema = action_schema
|
||||
self._client = client if client is not None else LegacyClient()
|
||||
self._tool_info = tool_info
|
||||
|
||||
def _run(self, **kwargs: Any) -> Any:
|
||||
def _run(self, **kwargs: Any) -> str | ToolFailure:
|
||||
try:
|
||||
cleaned_kwargs = {
|
||||
key: value for key, value in kwargs.items() if value is not None
|
||||
}
|
||||
|
||||
api_url = (
|
||||
f"{get_platform_api_base_url()}/actions/{self.action_name}/execute"
|
||||
)
|
||||
token = get_platform_integration_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"integration": cleaned_kwargs if cleaned_kwargs else {"_noop": True}
|
||||
}
|
||||
result = self._client.execute_action(self._tool_info, cleaned_kwargs)
|
||||
|
||||
response = requests.post(
|
||||
url=api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=60,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
if not response.ok:
|
||||
if isinstance(data, dict):
|
||||
error_info = data.get("error", {})
|
||||
if isinstance(error_info, dict):
|
||||
error_message = error_info.get("message", json.dumps(data))
|
||||
else:
|
||||
error_message = str(error_info)
|
||||
else:
|
||||
error_message = str(data)
|
||||
# A non-2xx here means the upstream app rejected the action
|
||||
# (e.g. Slack's channel_not_found) -- report it, not prose.
|
||||
if isinstance(result, ToolExecutionFailure):
|
||||
return ToolFailure(
|
||||
message=f"API request failed: {error_message}",
|
||||
code=str(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
details={"action": self.action_name},
|
||||
message=f"API request failed: {result.message}",
|
||||
code=result.code,
|
||||
retryable=result.retryable,
|
||||
details={"action": self._tool_info.action},
|
||||
)
|
||||
|
||||
return json.dumps(data, indent=2)
|
||||
return json.dumps(result.output, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return ToolFailure(
|
||||
message=f"Error executing action {self.action_name}: {e!s}",
|
||||
message=f"Error executing action {self._tool_info.action}: {e!s}",
|
||||
code=e.__class__.__name__,
|
||||
details={"action": self.action_name},
|
||||
details={"action": self._tool_info.action},
|
||||
)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""CrewAI platform tool builder for fetching and creating action tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
import requests
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_api_base_url,
|
||||
get_platform_integration_token,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrewaiPlatformToolBuilder:
|
||||
"""Builds platform tools from remote action schemas."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
apps: list[str],
|
||||
) -> None:
|
||||
self._apps = apps
|
||||
self._actions_schema: dict[str, dict[str, Any]] = {}
|
||||
self._tools: list[BaseTool] | None = None
|
||||
|
||||
def tools(self) -> list[BaseTool]:
|
||||
"""Fetch actions and return built tools."""
|
||||
if self._tools is None:
|
||||
self._fetch_actions()
|
||||
self._create_tools()
|
||||
return self._tools if self._tools is not None else []
|
||||
|
||||
def _fetch_actions(self) -> None:
|
||||
"""Fetch action schemas from the platform API."""
|
||||
actions_url = f"{get_platform_api_base_url()}/actions"
|
||||
headers = {"Authorization": f"Bearer {get_platform_integration_token()}"}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
actions_url,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
params={"apps": ",".join(self._apps)},
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch platform tools for apps {self._apps}: {e}")
|
||||
return
|
||||
|
||||
raw_data = response.json()
|
||||
|
||||
self._actions_schema = {}
|
||||
action_categories = raw_data.get("actions", {})
|
||||
|
||||
for app, action_list in action_categories.items():
|
||||
if isinstance(action_list, list):
|
||||
for action in action_list:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
if action_name := action.get("name"):
|
||||
action_schema = {
|
||||
"function": {
|
||||
"name": action_name,
|
||||
"description": action.get(
|
||||
"description", f"Execute {action_name}"
|
||||
),
|
||||
"parameters": action.get("parameters", {}),
|
||||
"app": app,
|
||||
}
|
||||
}
|
||||
self._actions_schema[action_name] = action_schema
|
||||
|
||||
def _create_tools(self) -> None:
|
||||
"""Create tool instances from fetched action schemas."""
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
for action_name, action_schema in self._actions_schema.items():
|
||||
function_details = action_schema.get("function", {})
|
||||
description = function_details.get("description", f"Execute {action_name}")
|
||||
|
||||
tool = CrewAIPlatformActionTool(
|
||||
description=description,
|
||||
app=function_details["app"],
|
||||
action_name=action_name,
|
||||
action_schema=action_schema,
|
||||
)
|
||||
|
||||
tools.append(tool)
|
||||
|
||||
self._tools = tools
|
||||
|
||||
def __enter__(self) -> list[BaseTool]:
|
||||
"""Enter context manager and return tools."""
|
||||
return self.tools()
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Exit context manager."""
|
||||
@@ -2,9 +2,12 @@ import logging
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
from crewai_tools.adapters.tool_collection import ToolCollection
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
|
||||
CrewaiPlatformToolBuilder,
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
ApplicationSelector,
|
||||
client_for_selector,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def CrewaiPlatformTools( # noqa: N802
|
||||
apps: list[str],
|
||||
) -> ToolCollection[BaseTool]:
|
||||
) -> list[BaseTool]:
|
||||
"""Factory function that returns crewai platform tools.
|
||||
|
||||
Args:
|
||||
@@ -22,6 +25,20 @@ def CrewaiPlatformTools( # noqa: N802
|
||||
Returns:
|
||||
A list of BaseTool instances for platform actions
|
||||
"""
|
||||
builder = CrewaiPlatformToolBuilder(apps=apps)
|
||||
selectors = [ApplicationSelector.from_string(app) for app in apps]
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
return builder.tools() # type: ignore
|
||||
try:
|
||||
for selector in selectors:
|
||||
client = client_for_selector(selector)
|
||||
tools.extend(
|
||||
CrewAIPlatformActionTool(tool_info, client=client)
|
||||
for tool_info in client.get_actions([selector])
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to fetch platform tools for apps {apps}: {error}")
|
||||
return []
|
||||
|
||||
return tools
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Contract and default client for platform integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
from crewai_core.plus_api import PlusAPI
|
||||
import requests
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_integration_token,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationSelector:
|
||||
"""Represent an application selector."""
|
||||
|
||||
app: str
|
||||
action: str | None
|
||||
connection_id: UUID | None
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> ApplicationSelector:
|
||||
"""Parse the ``application[/action][@connection_uuid]`` syntax.
|
||||
|
||||
Raises:
|
||||
ValueError: If the selector does not follow the supported syntax.
|
||||
"""
|
||||
if not value:
|
||||
raise ValueError(f"Invalid application selector {value!r}: cannot be empty")
|
||||
if "@" in value and "/" in value and value.index("@") < value.index("/"):
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be the last segment"
|
||||
)
|
||||
|
||||
app_and_action, connection_separator, connection_id = value.partition("@")
|
||||
app, action_separator, action = app_and_action.partition("/")
|
||||
|
||||
if not app:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: application cannot be empty"
|
||||
)
|
||||
if action_separator and not action:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: action cannot be empty"
|
||||
)
|
||||
if connection_separator and not connection_id:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: connection ID cannot be empty"
|
||||
)
|
||||
|
||||
parsed_connection_id = None
|
||||
if connection_id:
|
||||
try:
|
||||
parsed_connection_id = UUID(connection_id)
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be a valid UUID"
|
||||
) from error
|
||||
|
||||
return cls(
|
||||
app=app,
|
||||
action=action if action_separator else None,
|
||||
connection_id=parsed_connection_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolInfo:
|
||||
"""Describe a normalized platform action."""
|
||||
|
||||
app: str
|
||||
action: str
|
||||
connection_id: UUID | None
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Return the qualified tool name."""
|
||||
parts = [self.app, self.action]
|
||||
if self.connection_id is not None:
|
||||
parts.append(str(self.connection_id))
|
||||
return sanitize_tool_name("_".join(parts))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionSuccess:
|
||||
"""Represent a successful platform action execution."""
|
||||
|
||||
output: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionFailure:
|
||||
"""Represent an expected platform action failure."""
|
||||
|
||||
message: str
|
||||
code: str
|
||||
retryable: bool
|
||||
|
||||
|
||||
ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
|
||||
|
||||
|
||||
class IntegrationsClient(Protocol):
|
||||
"""Define the contract for platform integrations clients."""
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
|
||||
|
||||
class ClipperClient:
|
||||
"""Use the Clipper platform integrations API."""
|
||||
|
||||
_RESOURCE = "/clipper/v1"
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
base_url = f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}"
|
||||
headers = self._headers()
|
||||
tool_infos: list[ToolInfo] = []
|
||||
|
||||
for selector in selectors:
|
||||
url = f"{base_url}/applications/{selector.app}/tools"
|
||||
if selector.action is not None:
|
||||
url = f"{url}/{selector.action}"
|
||||
|
||||
params = (
|
||||
{"connection_id": str(selector.connection_id)}
|
||||
if selector.connection_id is not None
|
||||
else {}
|
||||
)
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=30,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()["data"]
|
||||
actions = data if selector.action is None else [data]
|
||||
|
||||
tool_infos.extend(
|
||||
ToolInfo(
|
||||
app=selector.app,
|
||||
action=action["slug"],
|
||||
connection_id=selector.connection_id,
|
||||
description=action["description"],
|
||||
parameters=action["input_schema"],
|
||||
)
|
||||
for action in actions
|
||||
)
|
||||
|
||||
return tool_infos
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
payload: dict[str, Any] = {"arguments": arguments}
|
||||
if tool.connection_id is not None:
|
||||
payload["connection_id"] = str(tool.connection_id)
|
||||
|
||||
response = requests.post(
|
||||
(
|
||||
f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}"
|
||||
f"/applications/{tool.app}/tools/{tool.action}/execute"
|
||||
),
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=60,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
if 200 <= response.status_code < 300:
|
||||
data = response.json()
|
||||
return ToolExecutionSuccess(output=data["data"]["output"])
|
||||
|
||||
try:
|
||||
error = response.json()["errors"][0]
|
||||
message = error["detail"]
|
||||
code = error["code"]
|
||||
except (
|
||||
requests.exceptions.JSONDecodeError,
|
||||
KeyError,
|
||||
IndexError,
|
||||
TypeError,
|
||||
):
|
||||
message = f"Upstream API request failed with status {response.status_code}."
|
||||
code = str(response.status_code)
|
||||
|
||||
return ToolExecutionFailure(
|
||||
message=message,
|
||||
code=code,
|
||||
retryable=500 <= response.status_code < 600,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {get_platform_integration_token()}",
|
||||
}
|
||||
deployment_instance_uuid = os.getenv("CREWAI_DEPLOYMENT_INSTANCE_UUID")
|
||||
if deployment_instance_uuid:
|
||||
headers["X-Crewai-Deployment-Instance-Id"] = deployment_instance_uuid
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
class LegacyClient:
|
||||
"""Use the existing CrewAI platform integrations API."""
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
apps = [
|
||||
f"{selector.app}/{selector.action}"
|
||||
if selector.action is not None
|
||||
else selector.app
|
||||
for selector in selectors
|
||||
]
|
||||
response = requests.get(
|
||||
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}/actions",
|
||||
headers={"Authorization": f"Bearer {get_platform_integration_token()}"},
|
||||
timeout=30,
|
||||
params={"apps": ",".join(apps)},
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
tool_infos: list[ToolInfo] = []
|
||||
action_categories = response.json().get("actions", {})
|
||||
for app, actions in action_categories.items():
|
||||
if not isinstance(actions, list):
|
||||
continue
|
||||
for action_data in actions:
|
||||
if not isinstance(action_data, dict):
|
||||
continue
|
||||
if action := action_data.get("name"):
|
||||
parameters = action_data.get("parameters", {})
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {}
|
||||
|
||||
tool_infos.extend(
|
||||
ToolInfo(
|
||||
app=app,
|
||||
action=action,
|
||||
connection_id=selector.connection_id,
|
||||
description=action_data.get(
|
||||
"description", f"Execute {action}"
|
||||
),
|
||||
parameters=parameters,
|
||||
)
|
||||
for selector in selectors
|
||||
if selector.app == app and selector.action in (None, action)
|
||||
)
|
||||
|
||||
return tool_infos
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
response = requests.post(
|
||||
url=(
|
||||
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}"
|
||||
f"/actions/{tool.action}/execute"
|
||||
),
|
||||
headers={
|
||||
"Authorization": f"Bearer {get_platform_integration_token()}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"integration": arguments if arguments else {"_noop": True}},
|
||||
timeout=60,
|
||||
allow_redirects=False,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
data = response.json()
|
||||
if not 200 <= response.status_code < 300:
|
||||
if isinstance(data, dict):
|
||||
error_info = data.get("error", {})
|
||||
if isinstance(error_info, dict):
|
||||
error_message = error_info.get("message", json.dumps(data))
|
||||
else:
|
||||
error_message = str(error_info)
|
||||
else:
|
||||
error_message = str(data)
|
||||
|
||||
return ToolExecutionFailure(
|
||||
message=str(error_message),
|
||||
code=str(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
)
|
||||
|
||||
return ToolExecutionSuccess(output=data)
|
||||
|
||||
|
||||
def client_for_selector(selector: ApplicationSelector) -> IntegrationsClient:
|
||||
"""Select the integrations client for an application selector."""
|
||||
if selector.connection_id is not None:
|
||||
return ClipperClient()
|
||||
return LegacyClient()
|
||||
@@ -1,14 +1,8 @@
|
||||
import os
|
||||
|
||||
|
||||
def get_platform_api_base_url() -> str:
|
||||
"""Get the platform API base URL from environment or use default."""
|
||||
base_url = os.getenv("CREWAI_PLUS_URL", "https://app.crewai.com")
|
||||
return f"{base_url}/crewai_plus/api/v1/integrations"
|
||||
|
||||
|
||||
def get_platform_integration_token() -> str:
|
||||
"""Get the platform API base URL from environment or use default."""
|
||||
"""Get the platform integration token from the environment."""
|
||||
token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or ""
|
||||
if not token:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,43 +1,96 @@
|
||||
from unittest.mock import patch, Mock
|
||||
import os
|
||||
from typing import cast
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from crewai.tools.tool_failure import ToolFailure
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
IntegrationsClient,
|
||||
ToolExecutionFailure,
|
||||
ToolExecutionSuccess,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestCrewAIPlatformActionToolVerify:
|
||||
"""Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable"""
|
||||
|
||||
def setup_method(self):
|
||||
self.action_schema = {
|
||||
"function": {
|
||||
"name": "test_action",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"test_param": {
|
||||
"type": "string",
|
||||
"description": "Test parameter"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def create_test_tool(self):
|
||||
return CrewAIPlatformActionTool(
|
||||
description="Test action tool",
|
||||
self.tool_info = ToolInfo(
|
||||
app="test_app",
|
||||
action_name="test_action",
|
||||
action_schema=self.action_schema
|
||||
action="test_action",
|
||||
connection_id=None,
|
||||
description="Test action tool",
|
||||
parameters={
|
||||
"properties": {
|
||||
"test_param": {
|
||||
"type": "string",
|
||||
"description": "Test parameter",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
|
||||
def create_test_tool(
|
||||
self, client: IntegrationsClient | None = None
|
||||
) -> CrewAIPlatformActionTool:
|
||||
return CrewAIPlatformActionTool(self.tool_info, client=client)
|
||||
|
||||
def test_run_serializes_success_output(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.return_value = ToolExecutionSuccess(
|
||||
output={"result": {"id": 42}}
|
||||
)
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value", optional_param=None
|
||||
)
|
||||
|
||||
assert result == '{\n "result": {\n "id": 42\n }\n}'
|
||||
assert client.execute_action.call_args.args[1] == {"test_param": "test_value"}
|
||||
|
||||
def test_run_converts_expected_failure(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.return_value = ToolExecutionFailure(
|
||||
message="Channel not found",
|
||||
code="404",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value"
|
||||
)
|
||||
|
||||
assert result == ToolFailure(
|
||||
message="API request failed: Channel not found",
|
||||
code="404",
|
||||
retryable=False,
|
||||
details={"action": "test_action"},
|
||||
)
|
||||
|
||||
def test_run_preserves_unexpected_exception_fallback(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.side_effect = ValueError("Invalid response JSON")
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value"
|
||||
)
|
||||
|
||||
assert result == ToolFailure(
|
||||
message="Error executing action test_action: Invalid response JSON",
|
||||
code="ValueError",
|
||||
details={"action": "test_action"},
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_default(self, mock_post):
|
||||
"""Test that _run uses SSL verification by default when CREWAI_FACTORY is not set"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -50,10 +103,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_factory_false(self, mock_post):
|
||||
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'false'"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -66,10 +119,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_factory_false_uppercase(self, mock_post):
|
||||
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -82,10 +135,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_without_ssl_verification_factory_true(self, mock_post):
|
||||
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'true'"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -98,10 +151,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is False
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_without_ssl_verification_factory_true_uppercase(self, mock_post):
|
||||
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools import (
|
||||
CrewAIPlatformActionTool,
|
||||
CrewaiPlatformToolBuilder,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCrewaiPlatformToolBuilder(unittest.TestCase):
|
||||
@pytest.fixture
|
||||
def platform_tool_builder(self):
|
||||
"""Create a CrewaiPlatformToolBuilder instance for testing"""
|
||||
return CrewaiPlatformToolBuilder(apps=["github", "slack"])
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_response(self):
|
||||
return {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
},
|
||||
"body": {"type": "string", "description": "Issue body"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a Slack message",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel name",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text",
|
||||
},
|
||||
},
|
||||
"required": ["channel", "text"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_success(self, mock_get):
|
||||
mock_api_response = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github", "slack/send_message"])
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = mock_api_response
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
|
||||
assert "/actions" in args[0]
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer test_token"
|
||||
assert kwargs["params"]["apps"] == "github,slack/send_message"
|
||||
|
||||
assert "create_issue" in builder._actions_schema
|
||||
assert (
|
||||
builder._actions_schema["create_issue"]["function"]["name"]
|
||||
== "create_issue"
|
||||
)
|
||||
|
||||
def test_fetch_actions_no_token(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with self.assertRaises(ValueError) as context:
|
||||
builder._fetch_actions()
|
||||
assert "No platform integration token found" in str(context.exception)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_create_tools(self, mock_get):
|
||||
mock_api_response = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a Slack message",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel name",
|
||||
}
|
||||
},
|
||||
"required": ["channel"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github", "slack"])
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = mock_api_response
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = builder.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert all(isinstance(tool, CrewAIPlatformActionTool) for tool in tools)
|
||||
|
||||
tool_names = [tool.action_name for tool in tools]
|
||||
assert "create_issue" in tool_names
|
||||
assert "send_message" in tool_names
|
||||
assert {tool.action_name: tool.app for tool in tools} == {
|
||||
"create_issue": "github",
|
||||
"send_message": "slack",
|
||||
}
|
||||
|
||||
github_tool = next((t for t in tools if t.action_name == "create_issue"), None)
|
||||
slack_tool = next((t for t in tools if t.action_name == "send_message"), None)
|
||||
|
||||
assert github_tool is not None
|
||||
assert slack_tool is not None
|
||||
assert "Create a GitHub issue" in github_tool.description
|
||||
assert "Send a Slack message" in slack_tool.description
|
||||
|
||||
def test_tools_caching(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
|
||||
cached_tools = []
|
||||
|
||||
def mock_create_tools():
|
||||
builder._tools = cached_tools
|
||||
|
||||
with (
|
||||
patch.object(builder, "_fetch_actions") as mock_fetch,
|
||||
patch.object(
|
||||
builder, "_create_tools", side_effect=mock_create_tools
|
||||
) as mock_create,
|
||||
):
|
||||
tools1 = builder.tools()
|
||||
assert mock_fetch.call_count == 1
|
||||
assert mock_create.call_count == 1
|
||||
|
||||
tools2 = builder.tools()
|
||||
assert mock_fetch.call_count == 1
|
||||
assert mock_create.call_count == 1
|
||||
|
||||
assert tools1 is tools2
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
def test_empty_apps_list(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=[])
|
||||
|
||||
with patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
) as mock_get:
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = builder.tools()
|
||||
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 0
|
||||
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs["params"]["apps"] == ""
|
||||
|
||||
class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase):
|
||||
"""Test suite for SSL verification behavior in CrewaiPlatformToolBuilder"""
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_default(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification by default when CREWAI_FACTORY is not set"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_factory_false(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'false'"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_factory_false_uppercase(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_without_ssl_verification_factory_true(self, mock_get):
|
||||
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'true'"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is False
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_without_ssl_verification_factory_true_uppercase(self, mock_get):
|
||||
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is False
|
||||
@@ -7,7 +7,7 @@ from crewai_tools.tools.crewai_platform_tools import CrewaiPlatformTools
|
||||
class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_basic(self, mock_get):
|
||||
mock_response = Mock()
|
||||
@@ -17,11 +17,11 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
assert tools is not None
|
||||
assert isinstance(tools, list)
|
||||
assert type(tools) is list
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_multiple_apps(self, mock_get):
|
||||
mock_response = Mock()
|
||||
@@ -73,18 +73,62 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
assert tools is not None
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 2
|
||||
assert [tool.name for tool in tools] == [
|
||||
"github_create_issue",
|
||||
"slack_send_message",
|
||||
]
|
||||
assert [tool.app for tool in tools] == ["github", "slack"]
|
||||
assert tools[0].description == "Create a GitHub issue"
|
||||
assert tools[1].description == "Send a Slack message"
|
||||
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert (
|
||||
"apps=github,slack" in args[0]
|
||||
or kwargs.get("params", {}).get("apps") == "github,slack"
|
||||
)
|
||||
assert [request.kwargs["params"] for request in mock_get.call_args_list] == [
|
||||
{"apps": "github"},
|
||||
{"apps": "slack"},
|
||||
]
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_invalid_parameter_schemas_do_not_abort_discovery(self, mock_get):
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": "invalid",
|
||||
},
|
||||
{
|
||||
"name": "close_issue",
|
||||
"description": "Close a GitHub issue",
|
||||
"parameters": [{"type": "string"}],
|
||||
},
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List GitHub issues",
|
||||
"parameters": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
|
||||
assert [tool.name for tool in tools] == [
|
||||
"github_create_issue",
|
||||
"github_close_issue",
|
||||
"github_list_issues",
|
||||
]
|
||||
assert all(tool.args_schema.model_fields == {} for tool in tools)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
def test_crewai_platform_tools_empty_apps(self):
|
||||
with patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
) as mock_get:
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
@@ -98,7 +142,7 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_api_error_handling(self, mock_get):
|
||||
mock_get.side_effect = Exception("API Error")
|
||||
@@ -113,3 +157,192 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
with self.assertRaises(ValueError) as context:
|
||||
CrewaiPlatformTools(apps=["github"])
|
||||
assert "No platform integration token found" in str(context.exception)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_discovered_tool_executes_through_legacy_api(self, mock_get, mock_post):
|
||||
discovery_response = Mock()
|
||||
discovery_response.raise_for_status.return_value = None
|
||||
discovery_response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = discovery_response
|
||||
execution_response = Mock(ok=True, status_code=200)
|
||||
execution_response.json.return_value = {"issue": 42}
|
||||
mock_post.return_value = execution_response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
result = tools[0].run(title="Contract test")
|
||||
|
||||
assert mock_get.call_args.kwargs["params"] == {"apps": "github"}
|
||||
assert mock_post.call_args.kwargs["url"].endswith(
|
||||
"/actions/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["json"] == {
|
||||
"integration": {"title": "Contract test"}
|
||||
}
|
||||
assert result == '{\n "issue": 42\n}'
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_connection_selects_clipper_api(self, mock_get, mock_post):
|
||||
discovery_response = Mock()
|
||||
discovery_response.raise_for_status.return_value = None
|
||||
discovery_response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
}
|
||||
mock_get.return_value = discovery_response
|
||||
execution_response = Mock(status_code=200)
|
||||
execution_response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = execution_response
|
||||
connection_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
tools = CrewaiPlatformTools(
|
||||
apps=[f"github/create_issue@{connection_id}"]
|
||||
)
|
||||
result = tools[0].run(title="Contract test")
|
||||
|
||||
assert mock_get.call_args.args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools/create_issue"
|
||||
)
|
||||
assert mock_get.call_args.kwargs["params"] == {
|
||||
"connection_id": connection_id
|
||||
}
|
||||
assert mock_post.call_args.args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["json"] == {
|
||||
"arguments": {"title": "Contract test"},
|
||||
"connection_id": connection_id,
|
||||
}
|
||||
assert result == '{\n "issue": 42\n}'
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_mixed_selectors_use_both_apis(self, mock_get):
|
||||
legacy_response = Mock()
|
||||
legacy_response.raise_for_status.return_value = None
|
||||
legacy_response.json.return_value = {"actions": {"slack": []}}
|
||||
clipper_response = Mock()
|
||||
clipper_response.raise_for_status.return_value = None
|
||||
clipper_response.json.return_value = {"data": []}
|
||||
mock_get.side_effect = [legacy_response, clipper_response]
|
||||
connection_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["slack", f"github@{connection_id}"])
|
||||
|
||||
assert tools == []
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_get.call_args_list[0].kwargs["params"] == {"apps": "slack"}
|
||||
assert mock_get.call_args_list[1].args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools"
|
||||
)
|
||||
assert mock_get.call_args_list[1].kwargs["params"] == {
|
||||
"connection_id": connection_id
|
||||
}
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_same_action_from_different_apps_has_unique_tool_names(self, mock_get):
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search GitHub",
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search Slack",
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github", "slack"])
|
||||
|
||||
assert len(tools) == 2
|
||||
assert [tool.name for tool in tools] == ["github_search", "slack_search"]
|
||||
assert [tool.app for tool in tools] == ["github", "slack"]
|
||||
assert [tool.description for tool in tools] == ["Search GitHub", "Search Slack"]
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_tool_name_uses_its_sanitized_identity(self, mock_get):
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "CreateFile!",
|
||||
"description": "Create a file",
|
||||
"input_schema": {},
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = CrewaiPlatformTools(
|
||||
apps=[
|
||||
"Google Drive/CreateFile!@550e8400-e29b-41d4-a716-446655440000"
|
||||
]
|
||||
)
|
||||
|
||||
assert tools[0].name == (
|
||||
"google_drive_create_file_550e8400_e29b_41d4_a716_446655440000"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import JSONDecodeError
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
ApplicationSelector,
|
||||
ClipperClient,
|
||||
IntegrationsClient,
|
||||
LegacyClient,
|
||||
ToolExecutionFailure,
|
||||
ToolExecutionSuccess,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None:
|
||||
index_response = Mock()
|
||||
index_response.raise_for_status.return_value = None
|
||||
index_response.json.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
]
|
||||
}
|
||||
show_response = Mock()
|
||||
show_response.raise_for_status.return_value = None
|
||||
show_response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
}
|
||||
mock_get.side_effect = [index_response, show_response]
|
||||
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
tools = ClipperClient().get_actions(
|
||||
[
|
||||
ApplicationSelector.from_string(f"github@{connection_id}"),
|
||||
ApplicationSelector.from_string("github/create_issue"),
|
||||
]
|
||||
)
|
||||
|
||||
expected_tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create a GitHub issue",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
assert tools == [
|
||||
expected_tool,
|
||||
ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create a GitHub issue",
|
||||
parameters={"type": "object"},
|
||||
),
|
||||
]
|
||||
headers = {
|
||||
"Authorization": "Bearer test_token",
|
||||
"X-Crewai-Deployment-Instance-Id": "deployment-instance-id",
|
||||
}
|
||||
assert mock_get.call_args_list == [
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools",
|
||||
headers=headers,
|
||||
params={"connection_id": str(connection_id)},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
),
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools/create_issue",
|
||||
headers=headers,
|
||||
params={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
),
|
||||
]
|
||||
index_response.raise_for_status.assert_called_once_with()
|
||||
show_response.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory_value", "verify"),
|
||||
[
|
||||
(None, True),
|
||||
("false", True),
|
||||
("FALSE", True),
|
||||
("true", False),
|
||||
("TRUE", False),
|
||||
],
|
||||
)
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_preserves_discovery_ssl_behavior(
|
||||
mock_get: Mock,
|
||||
factory_value: str | None,
|
||||
verify: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
if factory_value is None:
|
||||
monkeypatch.delenv("CREWAI_FACTORY", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CREWAI_FACTORY", factory_value)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"data": []}
|
||||
mock_get.return_value = response
|
||||
|
||||
ClipperClient().get_actions([ApplicationSelector.from_string("github")])
|
||||
|
||||
assert mock_get.call_args.kwargs["verify"] is verify
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "connection_id"),
|
||||
[
|
||||
({}, UUID("550e8400-e29b-41d4-a716-446655440000")),
|
||||
(
|
||||
{
|
||||
"filters": {"labels": ["urgent"], "enabled": True},
|
||||
"values": [1, {"key": "value"}],
|
||||
},
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clipper_client_executes_action(
|
||||
mock_post: Mock,
|
||||
arguments: dict[str, Any],
|
||||
connection_id: UUID | None,
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, arguments)
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
expected_payload: dict[str, Any] = {"arguments": arguments}
|
||||
if connection_id is not None:
|
||||
expected_payload["connection_id"] = str(connection_id)
|
||||
mock_post.assert_called_once_with(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools/create_issue/execute",
|
||||
headers={
|
||||
"Authorization": "Bearer test_token",
|
||||
"X-Crewai-Deployment-Instance-Id": "deployment-instance-id",
|
||||
},
|
||||
json=expected_payload,
|
||||
timeout=60,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "code", "detail", "retryable"),
|
||||
[
|
||||
(
|
||||
422,
|
||||
"tool_execution_failed",
|
||||
"The provider rejected the request.",
|
||||
False,
|
||||
),
|
||||
(
|
||||
503,
|
||||
"service_unavailable",
|
||||
"The tool provider is unavailable.",
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clipper_client_normalizes_execution_failure(
|
||||
mock_post: Mock,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
response = Mock(status_code=status_code)
|
||||
response.json.return_value = {
|
||||
"errors": [
|
||||
{
|
||||
"code": code,
|
||||
"detail": detail,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message=detail,
|
||||
code=code,
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_normalizes_non_json_service_failure(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=503)
|
||||
response.json.side_effect = JSONDecodeError("Expecting value", "", 0)
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message="Upstream API request failed with status 503.",
|
||||
code="503",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_discovers_without_deployment_instance_uuid(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"data": []}
|
||||
mock_get.return_value = response
|
||||
|
||||
assert ClipperClient().get_actions(
|
||||
[ApplicationSelector.from_string("github")]
|
||||
) == []
|
||||
assert mock_get.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token"
|
||||
}
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_executes_without_deployment_instance_uuid(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {})
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
assert mock_post.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token"
|
||||
}
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_requires_platform_integration_token(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CREWAI_PLATFORM_INTEGRATION_TOKEN"):
|
||||
ClipperClient().execute_action(tool, {})
|
||||
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_normalizes_discovered_actions(mock_get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[ApplicationSelector.from_string(f"github/create_issue@{connection_id}")]
|
||||
)
|
||||
|
||||
assert tools == [
|
||||
ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create a GitHub issue",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
]
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
assert mock_get.call_args.kwargs["params"] == {"apps": "github/create_issue"}
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_emits_action_for_each_matching_selector(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
app_connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
action_connection_id = UUID("8c5f9d69-902b-4b48-a23c-8d037c242e1e")
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[
|
||||
ApplicationSelector.from_string(f"github@{app_connection_id}"),
|
||||
ApplicationSelector.from_string(
|
||||
f"github/create_issue@{action_connection_id}"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert [tool.connection_id for tool in tools] == [
|
||||
app_connection_id,
|
||||
action_connection_id,
|
||||
]
|
||||
assert [tool.qualified_name for tool in tools] == [
|
||||
"github_create_issue_550e8400_e29b_41d4_a716_446655440000",
|
||||
"github_create_issue_8c5f9d69_902b_4b48_a23c_8d037c242e1e",
|
||||
]
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_excludes_actions_without_a_matching_selector(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "delete_issue",
|
||||
"description": "Delete a GitHub issue",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[ApplicationSelector.from_string("github/create_issue")]
|
||||
)
|
||||
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_tool_info_is_immutable() -> None:
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
tool_info.action = "delete_issue"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "field", "value"),
|
||||
[
|
||||
(ToolExecutionSuccess(output={"issue": 42}), "output", {"issue": 43}),
|
||||
(
|
||||
ToolExecutionFailure(
|
||||
message="Request failed", code="400", retryable=False
|
||||
),
|
||||
"message",
|
||||
"Another failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_tool_execution_results_are_immutable(
|
||||
result: ToolExecutionSuccess | ToolExecutionFailure,
|
||||
field: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(result, field, value)
|
||||
|
||||
|
||||
def test_application_selector_is_immutable() -> None:
|
||||
selector = ApplicationSelector.from_string(
|
||||
"github/create_issue@550e8400-e29b-41d4-a716-446655440000"
|
||||
)
|
||||
|
||||
assert selector.app == "github"
|
||||
assert selector.action == "create_issue"
|
||||
assert selector.connection_id == UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
selector.action = "delete_issue"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "message"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(
|
||||
"@550e8400-e29b-41d4-a716-446655440000",
|
||||
"application cannot be empty",
|
||||
),
|
||||
("github/", "action cannot be empty"),
|
||||
("github@", "connection ID cannot be empty"),
|
||||
("github@not-a-uuid", "connection ID must be a valid UUID"),
|
||||
(
|
||||
"github@550e8400-e29b-41d4-a716-446655440000/issues",
|
||||
"connection ID must be the last segment",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_application_selector_rejects_invalid_values(
|
||||
value: str, message: str
|
||||
) -> None:
|
||||
with pytest.raises(ValueError) as error:
|
||||
ApplicationSelector.from_string(value)
|
||||
|
||||
assert repr(value) in str(error.value)
|
||||
assert message in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory_value", "verify"),
|
||||
[
|
||||
(None, True),
|
||||
("false", True),
|
||||
("FALSE", True),
|
||||
("true", False),
|
||||
("TRUE", False),
|
||||
],
|
||||
)
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_preserves_discovery_ssl_behavior(
|
||||
mock_get: Mock,
|
||||
factory_value: str | None,
|
||||
verify: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
if factory_value is None:
|
||||
monkeypatch.delenv("CREWAI_FACTORY", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CREWAI_FACTORY", factory_value)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = response
|
||||
|
||||
LegacyClient().get_actions([ApplicationSelector.from_string("github")])
|
||||
|
||||
assert mock_get.call_args.kwargs["verify"] is verify
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "integration"),
|
||||
[({"title": "Contract test"}, {"title": "Contract test"}), ({}, {"_noop": True})],
|
||||
)
|
||||
def test_legacy_client_preserves_execution_request(
|
||||
mock_post: Mock,
|
||||
arguments: dict[str, Any],
|
||||
integration: dict[str, Any],
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"issue": 42}
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=UUID("550e8400-e29b-41d4-a716-446655440000"),
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
client: IntegrationsClient = LegacyClient()
|
||||
result = client.execute_action(tool_info, arguments)
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
mock_post.assert_called_once()
|
||||
assert mock_post.call_args.kwargs["url"].endswith(
|
||||
"/actions/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
assert mock_post.call_args.kwargs["json"] == {"integration": integration}
|
||||
assert mock_post.call_args.kwargs["timeout"] == 60
|
||||
assert mock_post.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("response_data", "status_code", "message", "retryable"),
|
||||
[
|
||||
({"error": {"message": "Invalid issue"}}, 400, "Invalid issue", False),
|
||||
({"error": "Rate limited"}, 429, "Rate limited", False),
|
||||
(["Service unavailable"], 503, "['Service unavailable']", True),
|
||||
({"reason": "Unknown"}, 500, '{"reason": "Unknown"}', True),
|
||||
],
|
||||
)
|
||||
def test_legacy_client_normalizes_execution_failures(
|
||||
mock_post: Mock,
|
||||
response_data: Any,
|
||||
status_code: int,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
response = Mock(status_code=status_code)
|
||||
response.json.return_value = response_data
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = LegacyClient().execute_action(tool_info, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message=message,
|
||||
code=str(status_code),
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_legacy_client_treats_redirect_as_execution_failure(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=302)
|
||||
response.json.return_value = {"error": {"message": "Redirected"}}
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = LegacyClient().execute_action(tool_info, {})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message="Redirected",
|
||||
code="302",
|
||||
retryable=False,
|
||||
)
|
||||
@@ -576,7 +576,7 @@ pip install dist/*.tar.gz
|
||||
|
||||
CrewAI uses anonymous telemetry to collect usage data with the main purpose of helping us improve the library by focusing our efforts on the most used features, integrations and tools.
|
||||
|
||||
It's pivotal to understand that **NO data is collected** concerning prompts, task descriptions, agents' backstories or goals, usage of tools, API calls, responses, any data processed by the agents, or secrets and environment variables, with the exception of the conditions mentioned. When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected to provide deeper insights while respecting user privacy. Users can disable telemetry by setting the environment variable OTEL_SDK_DISABLED to true.
|
||||
It's pivotal to understand that **NO data is collected** concerning prompts, task descriptions, agents' backstories or goals, usage of tools, API calls, responses, any data processed by the agents, or secrets and environment variables, with the exception of the conditions mentioned. When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected to provide deeper insights while respecting user privacy. Users can disable telemetry by setting `CREWAI_DISABLE_TELEMETRY` to `true`, `1`, `yes`, or `on`. `OTEL_SDK_DISABLED` with the same values also disables CrewAI telemetry; the OpenTelemetry SDK itself still only honors `true` for other instrumentation.
|
||||
|
||||
Data collected includes:
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ from crewai.events.types.memory_events import (
|
||||
)
|
||||
from crewai.events.types.skill_events import SkillUsedEvent
|
||||
from crewai.experimental.agent_executor import AgentExecutor
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.knowledge.knowledge import Knowledge
|
||||
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
|
||||
from crewai.lite_agent_output import LiteAgentOutput
|
||||
@@ -139,7 +140,10 @@ if TYPE_CHECKING:
|
||||
|
||||
# Deliberate stops, not transient errors: never swallowed into the
|
||||
# max_retry_limit loop.
|
||||
_passthrough_exceptions: tuple[type[Exception], ...] = (ToolExecutionFailedError,)
|
||||
_passthrough_exceptions: tuple[type[Exception], ...] = (
|
||||
ToolExecutionFailedError,
|
||||
HookAborted,
|
||||
)
|
||||
|
||||
_EXECUTOR_CLASS_MAP: dict[str, type] = {
|
||||
"CrewAgentExecutor": CrewAgentExecutor,
|
||||
@@ -711,6 +715,9 @@ class Agent(BaseAgent):
|
||||
error=str(e),
|
||||
),
|
||||
)
|
||||
# a deny aborts the task; any other failure degrades to no memory
|
||||
if isinstance(e, HookAborted):
|
||||
raise
|
||||
|
||||
return task_prompt
|
||||
|
||||
@@ -1438,6 +1445,18 @@ class Agent(BaseAgent):
|
||||
),
|
||||
)
|
||||
return rewritten_query
|
||||
except HookAborted as e:
|
||||
# A deny still owes the started event above its terminal event; only
|
||||
# the fallback to no query is skipped.
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
event=KnowledgeQueryFailedEvent(
|
||||
error=str(e),
|
||||
from_task=task,
|
||||
from_agent=self,
|
||||
),
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
crewai_event_bus.emit(
|
||||
self,
|
||||
@@ -1634,6 +1653,9 @@ class Agent(BaseAgent):
|
||||
error=str(e),
|
||||
),
|
||||
)
|
||||
# a deny aborts the kickoff; any other failure degrades to no memory
|
||||
if isinstance(e, HookAborted):
|
||||
raise
|
||||
|
||||
inputs: dict[str, Any] = {
|
||||
"input": formatted_messages,
|
||||
@@ -1815,6 +1837,8 @@ class Agent(BaseAgent):
|
||||
extracted = agent_memory.extract_memories(raw)
|
||||
if extracted:
|
||||
agent_memory.remember_many(extracted)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._logger.log("error", f"Failed to save kickoff result to memory: {e}")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from crewai.events.types.knowledge_events import (
|
||||
KnowledgeRetrievalStartedEvent,
|
||||
KnowledgeSearchQueryFailedEvent,
|
||||
)
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.knowledge.utils.knowledge_utils import extract_knowledge_context
|
||||
from crewai.utilities.pydantic_schema_utils import generate_model_description
|
||||
from crewai.utilities.types import LLMMessage
|
||||
@@ -53,6 +54,8 @@ def handle_reasoning(agent: Agent, task: Task) -> None:
|
||||
planning_handler.handle_agent_reasoning()
|
||||
)
|
||||
task.description += f"\n\nPlanning:\n{planning_output.plan.plan}"
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
agent._logger.log("error", f"Error during planning: {e!s}")
|
||||
|
||||
@@ -195,6 +198,9 @@ def handle_knowledge_retrieval(
|
||||
from_agent=agent,
|
||||
),
|
||||
)
|
||||
# a deny aborts the task; any other failure degrades to no knowledge
|
||||
if isinstance(e, HookAborted):
|
||||
raise
|
||||
return task_prompt
|
||||
|
||||
|
||||
@@ -391,4 +397,7 @@ async def ahandle_knowledge_retrieval(
|
||||
from_agent=agent,
|
||||
),
|
||||
)
|
||||
# a deny aborts the task; any other failure degrades to no knowledge
|
||||
if isinstance(e, HookAborted):
|
||||
raise
|
||||
return task_prompt
|
||||
|
||||
@@ -30,6 +30,8 @@ class BaseAgentExecutor(BaseModel):
|
||||
|
||||
def _save_to_memory(self, output: AgentFinish) -> None:
|
||||
"""Save task result to unified memory (memory or crew._memory)."""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if self.agent is None:
|
||||
return
|
||||
memory = getattr(self.agent, "memory", None) or (
|
||||
@@ -61,5 +63,7 @@ class BaseAgentExecutor(BaseModel):
|
||||
)
|
||||
else:
|
||||
memory.remember_many(extracted, agent_role=self.agent.role)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.agent._logger.log("error", f"Failed to save to memory: {e}")
|
||||
|
||||
@@ -131,7 +131,13 @@ class PlannerObserver:
|
||||
StepObservation with the Planner's analysis. Any suggested
|
||||
refinements are structured StepRefinement objects ready for
|
||||
direct application — no second LLM call needed.
|
||||
|
||||
Raises:
|
||||
HookAborted: A `pre_model_call` hook denied the observation call.
|
||||
Every other failure degrades to a conservative observation.
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
agent_role = self.agent.role
|
||||
|
||||
crewai_event_bus.emit(
|
||||
@@ -188,6 +194,21 @@ class PlannerObserver:
|
||||
|
||||
return observation
|
||||
|
||||
except HookAborted as e:
|
||||
# A deny still owes the started event above its terminal event; only
|
||||
# the conservative-replan fallback is skipped.
|
||||
crewai_event_bus.emit(
|
||||
self.agent,
|
||||
event=StepObservationFailedEvent(
|
||||
agent_role=agent_role,
|
||||
step_number=completed_step.step_number,
|
||||
step_description=completed_step.description,
|
||||
error=str(e),
|
||||
from_task=self.task,
|
||||
from_agent=self.agent,
|
||||
),
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Observation LLM call failed: {e}. Defaulting to conservative replan."
|
||||
|
||||
@@ -28,6 +28,7 @@ from crewai.events.types.tool_usage_events import (
|
||||
ToolUsageFinishedEvent,
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.tools.tool_failure import ToolExecutionFailedError
|
||||
from crewai.utilities.agent_utils import (
|
||||
build_text_tool_calling_fallback_message,
|
||||
@@ -181,7 +182,7 @@ class StepExecutor:
|
||||
tool_calls_made=tool_calls_made,
|
||||
execution_time=elapsed,
|
||||
)
|
||||
except ToolExecutionFailedError:
|
||||
except (ToolExecutionFailedError, HookAborted):
|
||||
# A deliberate stop: StepResult(success=False) would let the plan
|
||||
# carry on.
|
||||
raise
|
||||
@@ -224,7 +225,7 @@ class StepExecutor:
|
||||
tool_calls_made=tool_calls_made,
|
||||
execution_time=elapsed,
|
||||
)
|
||||
except ToolExecutionFailedError:
|
||||
except (ToolExecutionFailedError, HookAborted):
|
||||
# Same as the outer handler, reached via the text-tooling
|
||||
# fallback.
|
||||
raise
|
||||
|
||||
@@ -207,6 +207,9 @@ class Crew(FlowTrackable, BaseModel):
|
||||
|
||||
__hash__ = object.__hash__
|
||||
_execution_span: Span | None = PrivateAttr()
|
||||
# Monotonic stamp for the ungated "Crew Completed" span. A PrivateAttr
|
||||
# because Crew is a BaseModel, unlike Flow which takes a plain attribute.
|
||||
_telemetry_started_at: float | None = PrivateAttr(default=None)
|
||||
_rpm_controller: RPMController = PrivateAttr()
|
||||
_logger: Logger = PrivateAttr()
|
||||
_file_handler: FileHandler = PrivateAttr()
|
||||
|
||||
@@ -182,17 +182,36 @@ class EventListener(BaseEventListener):
|
||||
def on_default_env(_: Any, event: DefaultEnvEvent) -> None:
|
||||
self._telemetry.env_context_span(event.type)
|
||||
|
||||
def _report_crew_duration(source: Any, outcome: str) -> None:
|
||||
"""Emit the elapsed time for a crew that reached a terminal state.
|
||||
|
||||
A missing stamp means "no duration to report", not an error: a
|
||||
listener attached mid-run, or a completion re-emitted for a run this
|
||||
listener never saw start, both land here legitimately. The stamp is
|
||||
cleared so a second terminal event on the same instance cannot
|
||||
report a duration twice.
|
||||
"""
|
||||
started_at = getattr(source, "_telemetry_started_at", None)
|
||||
if started_at is None:
|
||||
return
|
||||
source._telemetry_started_at = None
|
||||
self._telemetry.crew_completed_span(
|
||||
source, (time.monotonic() - started_at) * 1000, outcome
|
||||
)
|
||||
|
||||
@crewai_event_bus.on(CrewKickoffStartedEvent)
|
||||
def on_crew_started(source: Any, event: CrewKickoffStartedEvent) -> None:
|
||||
self.formatter.handle_crew_started(event.crew_name or "Crew", source.id)
|
||||
source._execution_span = self._telemetry.crew_execution_span(
|
||||
source, event.inputs
|
||||
)
|
||||
source._telemetry_started_at = time.monotonic()
|
||||
|
||||
@crewai_event_bus.on(CrewKickoffCompletedEvent)
|
||||
def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None:
|
||||
final_string_output = event.output.raw
|
||||
self._telemetry.end_crew(source, final_string_output)
|
||||
_report_crew_duration(source, "completed")
|
||||
|
||||
self.formatter.handle_crew_status(
|
||||
event.crew_name or "Crew",
|
||||
@@ -203,6 +222,10 @@ class EventListener(BaseEventListener):
|
||||
|
||||
@crewai_event_bus.on(CrewKickoffFailedEvent)
|
||||
def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None:
|
||||
# end_crew is deliberately not called here: it writes onto the
|
||||
# share_crew-gated execution span, which a failed run may never have
|
||||
# opened. This span is the only terminal record for a failed crew.
|
||||
_report_crew_duration(source, "failed")
|
||||
self.formatter.handle_crew_status(
|
||||
event.crew_name or "Crew",
|
||||
source.id,
|
||||
|
||||
@@ -57,6 +57,7 @@ from crewai.events.types.tool_usage_events import (
|
||||
from crewai.flow.flow import Flow, listen, or_, router, start
|
||||
from crewai.flow.flow_context import current_flow_id
|
||||
from crewai.flow.types import FlowMethodName
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.hooks.llm_hooks import (
|
||||
get_after_llm_call_hooks,
|
||||
get_before_llm_call_hooks,
|
||||
@@ -420,6 +421,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
# Do NOT mutate task.description — it's a shared object that
|
||||
# accumulates plan text on re-invoke.
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
if hasattr(self.agent, "_logger"):
|
||||
self.agent._logger.log("error", f"Error during planning: {e!s}")
|
||||
@@ -1295,6 +1298,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
# todo ↔ result (or exception) mapping.
|
||||
step_results: list[tuple[TodoItem, StepResult]] = []
|
||||
for todo, item in zip(ready, gathered, strict=True):
|
||||
if isinstance(item, HookAborted):
|
||||
raise item
|
||||
if isinstance(item, BaseException):
|
||||
error_msg = f"Error: {item!s}"
|
||||
todo.result = error_msg
|
||||
@@ -2552,6 +2557,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
)
|
||||
return
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self.agent and self.agent.verbose:
|
||||
PRINTER.print(
|
||||
@@ -2704,6 +2711,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
|
||||
color="green",
|
||||
)
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
if hasattr(self.agent, "_logger"):
|
||||
self.agent._logger.log("error", f"Error during replanning: {e!s}")
|
||||
|
||||
@@ -311,6 +311,8 @@ class EvaluationDisplayFormatter:
|
||||
scores: list[float | None],
|
||||
strategy: AggregationStrategy,
|
||||
) -> str:
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if len(feedbacks) <= 2 and all(len(fb) < 200 for fb in feedbacks):
|
||||
return "\n\n".join(
|
||||
[f"Feedback {i + 1}: {fb}" for i, fb in enumerate(feedbacks)]
|
||||
@@ -372,6 +374,8 @@ class EvaluationDisplayFormatter:
|
||||
raise ValueError("LLM must be initialized")
|
||||
return llm.call(prompt)
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception:
|
||||
return "Synthesized from multiple tasks: " + "\n\n".join(
|
||||
[f"- {fb[:500]}..." for fb in feedbacks]
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = ["HumanFeedbackResult", "human_feedback"]
|
||||
def human_feedback(
|
||||
message: str,
|
||||
emit: Sequence[str] | None = None,
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini",
|
||||
llm: str | BaseLLM | None = None,
|
||||
default_outcome: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
provider: HumanFeedbackProvider | None = None,
|
||||
@@ -37,9 +37,7 @@ def human_feedback(
|
||||
configuration on the method, and the Flow engine collects and routes
|
||||
feedback after the method completes, driven by the flow's definition.
|
||||
"""
|
||||
_validate_human_feedback_options(
|
||||
emit=emit, llm=llm, default_outcome=default_outcome
|
||||
)
|
||||
_validate_human_feedback_options(emit=emit, default_outcome=default_outcome)
|
||||
config = HumanFeedbackConfig(
|
||||
message=message,
|
||||
emit=list(emit) if emit is not None else None,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, cast
|
||||
@@ -21,6 +23,46 @@ _CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CelRunContext:
|
||||
now: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CelFunctionSpec:
|
||||
annotation: Any
|
||||
factory: Callable[[_CelRunContext], Callable[..., Any]]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _cel_function_registry() -> dict[str, _CelFunctionSpec]:
|
||||
from celpy import celtypes
|
||||
|
||||
return {
|
||||
"now": _CelFunctionSpec(
|
||||
annotation=celtypes.FunctionType,
|
||||
factory=lambda run: lambda: celtypes.TimestampType(run.now),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _cel_environment() -> Any:
|
||||
from celpy import Environment
|
||||
|
||||
return Environment(
|
||||
annotations={
|
||||
name: spec.annotation for name, spec in _cel_function_registry().items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _cel_functions(run_context: _CelRunContext) -> dict[str, Any]:
|
||||
return {
|
||||
name: spec.factory(run_context)
|
||||
for name, spec in _cel_function_registry().items()
|
||||
}
|
||||
|
||||
|
||||
def _find_cel_eval_error(value: Any) -> Exception | None:
|
||||
from celpy.evaluation import CELEvalError
|
||||
|
||||
@@ -103,6 +145,9 @@ FLOW_TEMPLATE_EXPRESSION_RULES: tuple[str, ...] = (
|
||||
"Use this for numbers, booleans, objects, and lists.",
|
||||
"If the string has other text, the final value is text. Non-text values "
|
||||
"become JSON. `null` becomes empty text.",
|
||||
"Use `now()` for the current UTC time as a CEL timestamp, frozen for the "
|
||||
"whole run. Use standard CEL on it: `string(now())` for ISO text, "
|
||||
"`now().getFullYear()`, or `now() - duration('24h')`.",
|
||||
)
|
||||
FLOW_TEMPLATE_EXPRESSION_CONTRACT = " ".join(FLOW_TEMPLATE_EXPRESSION_RULES)
|
||||
FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
|
||||
@@ -176,10 +221,15 @@ class Expression:
|
||||
"""CEL expression helper used for definition-time checks and runtime rendering."""
|
||||
|
||||
def __init__(
|
||||
self, value: ExpressionData, *, context: dict[str, Any] | None = None
|
||||
self,
|
||||
value: ExpressionData,
|
||||
*,
|
||||
context: dict[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
self.value = value
|
||||
self.context = context
|
||||
self.now = now
|
||||
|
||||
@classmethod
|
||||
def from_flow(
|
||||
@@ -190,7 +240,11 @@ class Expression:
|
||||
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))
|
||||
return cls(
|
||||
value,
|
||||
context=cls._flow_context(flow, local_context=local_context),
|
||||
now=getattr(flow, "_cel_now", None),
|
||||
)
|
||||
|
||||
def validate_expression(
|
||||
self,
|
||||
@@ -231,6 +285,7 @@ class Expression:
|
||||
return self._evaluate_cel(
|
||||
self._require_cel_source(cast(str, self.value)),
|
||||
resolved_context or {},
|
||||
self._run_context(),
|
||||
)
|
||||
|
||||
def render_template(self, context: dict[str, Any] | None = None) -> Any:
|
||||
@@ -240,7 +295,12 @@ class Expression:
|
||||
type; strings mixing literals and expressions render as text.
|
||||
"""
|
||||
resolved_context = self.context if context is None else context
|
||||
return self._render_template_value(self.value, resolved_context or {})
|
||||
return self._render_template_value(
|
||||
self.value, resolved_context or {}, self._run_context()
|
||||
)
|
||||
|
||||
def _run_context(self) -> _CelRunContext:
|
||||
return _CelRunContext(now=self.now or datetime.now(timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
def _validate_template_value(
|
||||
@@ -311,20 +371,27 @@ class Expression:
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _render_template_value(value: ExpressionData, context: dict[str, Any]) -> Any:
|
||||
def _render_template_value(
|
||||
value: ExpressionData, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
if isinstance(value, str):
|
||||
return Expression._render_template_string(value, context)
|
||||
return Expression._render_template_string(value, context, run_context)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: Expression._render_template_value(item, context)
|
||||
key: Expression._render_template_value(item, context, run_context)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [Expression._render_template_value(item, context) for item in value]
|
||||
return [
|
||||
Expression._render_template_value(item, context, run_context)
|
||||
for item in value
|
||||
]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _render_template_string(value: str, context: dict[str, Any]) -> Any:
|
||||
def _render_template_string(
|
||||
value: str, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
segments = _parse_template_segments(value)
|
||||
expressions = [
|
||||
segment for segment in segments if isinstance(segment, _ExpressionSegment)
|
||||
@@ -333,26 +400,28 @@ class Expression:
|
||||
return value
|
||||
literals = [segment for segment in segments if isinstance(segment, str)]
|
||||
if len(expressions) == 1 and all(not literal.strip() for literal in literals):
|
||||
return Expression._evaluate_cel(expressions[0].source, context)
|
||||
return Expression._evaluate_cel(expressions[0].source, context, run_context)
|
||||
rendered: list[str] = []
|
||||
for segment in segments:
|
||||
if isinstance(segment, str):
|
||||
rendered.append(segment)
|
||||
continue
|
||||
result = Expression._evaluate_cel(segment.source, context)
|
||||
result = Expression._evaluate_cel(segment.source, context, run_context)
|
||||
rendered.append("" if result is None else _stringify_cel_value(result))
|
||||
return "".join(rendered)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
|
||||
def _evaluate_cel(
|
||||
expression: str, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
try:
|
||||
from celpy import Environment
|
||||
from celpy.adapter import CELJSONEncoder, json_to_cel
|
||||
from celpy.evaluation import Context
|
||||
|
||||
environment = Environment()
|
||||
environment = _cel_environment()
|
||||
program = environment.program(
|
||||
Expression._compile_cel(expression, environment=environment)
|
||||
Expression._compile_cel(expression, environment=environment),
|
||||
functions=_cel_functions(run_context),
|
||||
)
|
||||
result = program.evaluate(cast(Context, json_to_cel(context)))
|
||||
if (eval_error := _find_cel_eval_error(result)) is not None:
|
||||
@@ -371,9 +440,7 @@ class Expression:
|
||||
environment: Any | None = None,
|
||||
) -> Any:
|
||||
if environment is None:
|
||||
from celpy import Environment
|
||||
|
||||
environment = Environment()
|
||||
environment = _cel_environment()
|
||||
try:
|
||||
return environment.compile(expression)
|
||||
except Exception as e:
|
||||
|
||||
@@ -295,8 +295,12 @@ class FlowHumanFeedbackDefinition(BaseModel):
|
||||
examples=[["approved", "revise"]],
|
||||
)
|
||||
llm: Any = Field(
|
||||
default="gpt-4o-mini",
|
||||
description="LLM configuration used to assist or process human feedback.",
|
||||
default=None,
|
||||
description=(
|
||||
"LLM used to collapse feedback to an emit outcome. "
|
||||
"None resolves at runtime via create_llm (project MODEL env, "
|
||||
"then DEFAULT_LLM_MODEL)."
|
||||
),
|
||||
examples=["gpt-4o-mini"],
|
||||
)
|
||||
default_outcome: str | None = Field(
|
||||
@@ -1006,14 +1010,6 @@ def log_flow_definition_issues(definition: FlowDefinition) -> None:
|
||||
)
|
||||
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
|
||||
|
||||
@@ -77,7 +77,37 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
__all__ = ["HumanFeedbackResult", "human_feedback"]
|
||||
__all__ = ["HumanFeedbackCollapseError", "HumanFeedbackResult", "human_feedback"]
|
||||
|
||||
|
||||
class HumanFeedbackCollapseError(ValueError):
|
||||
"""Raised when human feedback cannot be mapped to an emit outcome."""
|
||||
|
||||
|
||||
def _match_collapse_outcome(response_text: str, outcomes: Sequence[str]) -> str | None:
|
||||
"""Return the emit label that best matches ``response_text``, or None."""
|
||||
response_clean = response_text.strip()
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
response_lower = response_clean.lower()
|
||||
best_outcome: str | None = None
|
||||
best_len = -1
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() in response_lower and len(outcome) > best_len:
|
||||
best_outcome = outcome
|
||||
best_len = len(outcome)
|
||||
return best_outcome
|
||||
|
||||
|
||||
def _require_collapse_outcome(response_text: str, outcomes: Sequence[str]) -> str:
|
||||
matched = _match_collapse_outcome(response_text, outcomes)
|
||||
if matched is None:
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not match LLM response {response_text!r} to outcomes "
|
||||
f"{list(outcomes)}."
|
||||
)
|
||||
return matched
|
||||
|
||||
|
||||
def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
|
||||
@@ -165,7 +195,10 @@ class HumanFeedbackConfig:
|
||||
Attributes:
|
||||
message: The message shown to the human when requesting feedback.
|
||||
emit: Optional sequence of outcome strings for routing.
|
||||
llm: The LLM model to use for collapsing feedback to outcomes.
|
||||
llm: The LLM used to collapse feedback to an emit outcome.
|
||||
None means resolve at runtime via create_llm (decorator
|
||||
value, then MODEL / MODEL_NAME / OPENAI_MODEL_NAME, then
|
||||
DEFAULT_LLM_MODEL).
|
||||
default_outcome: The outcome to use when no feedback is provided.
|
||||
metadata: Optional metadata for enterprise integrations.
|
||||
provider: Optional custom feedback provider for async workflows.
|
||||
@@ -173,7 +206,7 @@ class HumanFeedbackConfig:
|
||||
|
||||
message: str
|
||||
emit: Sequence[str] | None = None
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini"
|
||||
llm: str | BaseLLM | None = None
|
||||
default_outcome: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
provider: HumanFeedbackProvider | None = None
|
||||
@@ -205,17 +238,9 @@ class DistilledLessons(BaseModel):
|
||||
|
||||
def _validate_human_feedback_options(
|
||||
emit: Sequence[str] | None,
|
||||
llm: Any,
|
||||
default_outcome: str | None,
|
||||
) -> None:
|
||||
if emit is not None:
|
||||
if not llm:
|
||||
raise ValueError(
|
||||
"llm is required when emit is specified. "
|
||||
"Provide an LLM model string (e.g., 'gpt-5.4-mini') or a BaseLLM instance. "
|
||||
"See the CrewAI Human-in-the-Loop (HITL) documentation for more information: "
|
||||
"https://docs.crewai.com/en/learn/human-feedback-in-flows"
|
||||
)
|
||||
if default_outcome is not None and default_outcome not in emit:
|
||||
raise ValueError(
|
||||
f"default_outcome '{default_outcome}' must be one of the "
|
||||
@@ -232,16 +257,23 @@ def _get_hitl_prompt(key: str) -> str:
|
||||
|
||||
|
||||
def _resolve_llm_instance(llm: Any) -> Any:
|
||||
"""Resolve a collapse/learn LLM the same way agents resolve theirs.
|
||||
|
||||
Explicit decorator values win. ``None`` follows ``create_llm``:
|
||||
``MODEL`` / ``MODEL_NAME`` / ``OPENAI_MODEL_NAME``, then
|
||||
``DEFAULT_LLM_MODEL``.
|
||||
"""
|
||||
from crewai.llm import LLM
|
||||
from crewai.utilities.llm_utils import create_llm
|
||||
|
||||
if llm is None:
|
||||
return LLM(model="gpt-5.4-mini")
|
||||
return create_llm(None)
|
||||
if isinstance(llm, str):
|
||||
return LLM(model=llm)
|
||||
if isinstance(llm, dict):
|
||||
deserialized = _deserialize_llm_from_context(llm)
|
||||
return deserialized if deserialized is not None else LLM(model="gpt-5.4-mini")
|
||||
return llm # already a BaseLLM instance
|
||||
return deserialized if deserialized is not None else create_llm(None)
|
||||
return llm
|
||||
|
||||
|
||||
def _pre_review_with_lessons(
|
||||
@@ -253,6 +285,8 @@ def _pre_review_with_lessons(
|
||||
learn_source: str,
|
||||
learn_strict: bool,
|
||||
) -> Any:
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
try:
|
||||
mem = flow_instance.memory
|
||||
if mem is None:
|
||||
@@ -282,6 +316,8 @@ def _pre_review_with_lessons(
|
||||
return PreReviewResult.model_validate(response).improved_output
|
||||
reviewed = llm_inst.call(messages)
|
||||
return reviewed if isinstance(reviewed, str) else str(reviewed)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception:
|
||||
if learn_strict:
|
||||
logger.warning(
|
||||
@@ -308,6 +344,8 @@ def _distill_and_store_lessons(
|
||||
learn_source: str,
|
||||
learn_strict: bool,
|
||||
) -> None:
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
try:
|
||||
mem = flow_instance.memory
|
||||
if mem is None:
|
||||
@@ -344,6 +382,8 @@ def _distill_and_store_lessons(
|
||||
|
||||
if lessons:
|
||||
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception:
|
||||
if learn_strict:
|
||||
logger.warning(
|
||||
@@ -362,7 +402,7 @@ def _distill_and_store_lessons(
|
||||
def human_feedback(
|
||||
message: str,
|
||||
emit: Sequence[str] | None = None,
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini",
|
||||
llm: str | BaseLLM | None = None,
|
||||
default_outcome: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
provider: HumanFeedbackProvider | None = None,
|
||||
|
||||
@@ -13,7 +13,7 @@ from collections.abc import Callable, Iterator, Sequence
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
import contextvars
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
import inspect
|
||||
import logging
|
||||
@@ -110,10 +110,12 @@ from crewai.flow.flow_wrappers import (
|
||||
StartMethod,
|
||||
)
|
||||
from crewai.flow.human_feedback import (
|
||||
HumanFeedbackCollapseError,
|
||||
HumanFeedbackResult,
|
||||
_deserialize_llm_from_context,
|
||||
_distill_and_store_lessons,
|
||||
_pre_review_with_lessons,
|
||||
_require_collapse_outcome,
|
||||
_resolve_llm_instance,
|
||||
_serialize_llm_for_context,
|
||||
)
|
||||
from crewai.flow.input_provider import InputProvider
|
||||
@@ -772,6 +774,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
# duration span emitted at the end does not need to hold a span open for
|
||||
# the life of the run.
|
||||
_telemetry_started_at: float | None = PrivateAttr(default=None)
|
||||
_cel_now: datetime | None = PrivateAttr(default=None)
|
||||
_event_futures: list[Future[None]] = PrivateAttr(default_factory=list)
|
||||
_pending_feedback_context: PendingFeedbackContext | None = PrivateAttr(default=None)
|
||||
_human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
@@ -1382,6 +1385,10 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
"No pending feedback context. Use from_pending() to restore a paused flow."
|
||||
)
|
||||
|
||||
# A fresh instant, not the persisted kickoff one: a flow can pause on
|
||||
# feedback for days, and expressions after resume must see today.
|
||||
self._cel_now = datetime.now(timezone.utc)
|
||||
|
||||
execution_token = begin_execution(self._pending_feedback_context.execution_uuid)
|
||||
|
||||
# Force `current_flow_id` to this flow's match id for the
|
||||
@@ -2171,6 +2178,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
restore_from_state_id=restore_from_state_id,
|
||||
)
|
||||
|
||||
self._cel_now = datetime.now(timezone.utc)
|
||||
|
||||
ctx = baggage.set_baggage("flow_inputs", inputs or {})
|
||||
ctx = baggage.set_baggage("flow_input_files", input_files or {}, context=ctx)
|
||||
flow_token = attach(ctx)
|
||||
@@ -3606,13 +3615,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
method_output: Any,
|
||||
) -> Any:
|
||||
llm = feedback_definition.llm
|
||||
llm_instance = (
|
||||
_deserialize_llm_from_context(llm) if isinstance(llm, (str, dict)) else llm
|
||||
)
|
||||
emit = feedback_definition.emit
|
||||
default_outcome = feedback_definition.default_outcome
|
||||
metadata = feedback_definition.metadata
|
||||
learn = feedback_definition.learn and self.memory is not None
|
||||
llm_instance = _resolve_llm_instance(llm) if (emit or learn) else llm
|
||||
|
||||
if learn:
|
||||
method_output = await asyncio.to_thread(
|
||||
@@ -3705,22 +3712,23 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
elif emit:
|
||||
collapsed_outcome = emit[0]
|
||||
elif emit:
|
||||
collapse_llm = (
|
||||
_deserialize_llm_from_context(llm)
|
||||
if isinstance(llm, (str, dict))
|
||||
else llm
|
||||
)
|
||||
if collapse_llm is not None:
|
||||
collapsed_outcome = await asyncio.to_thread(
|
||||
self._collapse_to_outcome,
|
||||
feedback=raw_feedback,
|
||||
outcomes=emit,
|
||||
llm=collapse_llm,
|
||||
collapse_llm = _resolve_llm_instance(llm)
|
||||
if collapse_llm is None:
|
||||
raise HumanFeedbackCollapseError(
|
||||
"Could not resolve an LLM to classify human feedback. "
|
||||
"Set llm= on @human_feedback or MODEL / MODEL_NAME / "
|
||||
"OPENAI_MODEL_NAME."
|
||||
)
|
||||
else:
|
||||
collapsed_outcome = emit[0]
|
||||
collapsed_outcome = await asyncio.to_thread(
|
||||
self._collapse_to_outcome,
|
||||
feedback=raw_feedback,
|
||||
outcomes=emit,
|
||||
llm=collapse_llm,
|
||||
)
|
||||
if emit and collapsed_outcome is None:
|
||||
collapsed_outcome = default_outcome or emit[0]
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not classify human feedback into one of {list(emit)}."
|
||||
)
|
||||
|
||||
result = HumanFeedbackResult(
|
||||
output=method_output,
|
||||
@@ -3842,11 +3850,16 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
|
||||
Returns:
|
||||
One of the outcome strings that best matches the feedback intent.
|
||||
|
||||
Raises:
|
||||
HumanFeedbackCollapseError: If the LLM cannot be called or its
|
||||
response cannot be mapped to one of ``outcomes``.
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
|
||||
from crewai.utilities.i18n import I18N_DEFAULT
|
||||
@@ -3881,27 +3894,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_model=FeedbackOutcome,
|
||||
)
|
||||
|
||||
if isinstance(response, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
return str(parsed.get("outcome", outcomes[0]))
|
||||
except json.JSONDecodeError:
|
||||
response_clean = response.strip()
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
return outcomes[0]
|
||||
elif isinstance(response, FeedbackOutcome):
|
||||
return str(response.outcome)
|
||||
elif hasattr(response, "outcome"):
|
||||
return str(response.outcome)
|
||||
else:
|
||||
logger.warning(f"Unexpected response type: {type(response)}")
|
||||
return outcomes[0]
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Structured output failed, falling back to simple prompting: {e}"
|
||||
@@ -3910,35 +3904,34 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
response = llm_instance.call(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
response_clean = str(response).strip()
|
||||
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
|
||||
# Partial match (longest wins, first on length ties)
|
||||
response_lower = response_clean.lower()
|
||||
best_outcome: str | None = None
|
||||
best_len = -1
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() in response_lower and len(outcome) > best_len:
|
||||
best_outcome = outcome
|
||||
best_len = len(outcome)
|
||||
if best_outcome is not None:
|
||||
return best_outcome
|
||||
|
||||
logger.warning(
|
||||
f"Could not match LLM response '{response_clean}' to outcomes {list(outcomes)}. "
|
||||
f"Falling back to first outcome: {outcomes[0]}"
|
||||
)
|
||||
return outcomes[0]
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as fallback_err:
|
||||
logger.warning(
|
||||
f"Simple prompting also failed: {fallback_err}. "
|
||||
f"Falling back to first outcome: {outcomes[0]}"
|
||||
)
|
||||
return outcomes[0]
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not classify human feedback into {list(outcomes)}: "
|
||||
f"{fallback_err}"
|
||||
) from fallback_err
|
||||
return _require_collapse_outcome(str(response), outcomes)
|
||||
|
||||
if isinstance(response, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
return _require_collapse_outcome(response, outcomes)
|
||||
if isinstance(parsed, dict):
|
||||
outcome = parsed.get("outcome")
|
||||
if isinstance(outcome, str):
|
||||
return _require_collapse_outcome(outcome, outcomes)
|
||||
return _require_collapse_outcome(response, outcomes)
|
||||
if isinstance(response, FeedbackOutcome):
|
||||
return str(response.outcome)
|
||||
if hasattr(response, "outcome"):
|
||||
return _require_collapse_outcome(str(response.outcome), outcomes)
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Unexpected collapse response type: {type(response)}"
|
||||
)
|
||||
|
||||
def _log_flow_event(
|
||||
self,
|
||||
|
||||
@@ -209,7 +209,7 @@ def _resolve_hooks(point: InterceptionPoint) -> list[HookFn]:
|
||||
return global_hooks
|
||||
|
||||
|
||||
def _source_name(source: Any) -> str | None:
|
||||
def source_name(source: Any) -> str | None:
|
||||
"""Best-effort readable name for a hook source."""
|
||||
if source is None:
|
||||
return None
|
||||
@@ -341,7 +341,7 @@ def run_hooks(
|
||||
except HookAborted as aborted:
|
||||
outcome = "aborted"
|
||||
abort_reason = aborted.reason
|
||||
abort_source = _source_name(aborted.source)
|
||||
abort_source = source_name(aborted.source)
|
||||
raise
|
||||
finally:
|
||||
_emit_telemetry(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
@@ -166,14 +169,50 @@ _after_llm_call_hooks: list[AfterLLMCallHookType | AfterLLMCallHookCallable] = (
|
||||
)
|
||||
|
||||
|
||||
class LegacyHookBlocked(HookAborted):
|
||||
"""A ``before_llm_call`` hook blocked the call by returning ``False``.
|
||||
|
||||
Distinguishes the boolean convention, which the LLM layer keeps surfacing as
|
||||
the documented ``ValueError``, from a hook that raised :class:`HookAborted`
|
||||
itself and must reach the caller as the deny it is. Raised by the reducer and
|
||||
consumed inside the LLM layer, which re-raises it as
|
||||
:class:`~crewai.llms.base_llm.LLMCallBlockedError`.
|
||||
"""
|
||||
|
||||
|
||||
_model_call_hooks_dispatched: ContextVar[bool] = ContextVar(
|
||||
"model_call_hooks_dispatched", default=False
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def model_call_hooks_dispatched() -> Iterator[None]:
|
||||
"""Mark the window where the model-call hooks already ran for a pending call.
|
||||
|
||||
The executor dispatches with its own richer context (executor, task, crew)
|
||||
and only then reaches the LLM. Without this marker the LLM layer would
|
||||
dispatch a second time for the same call.
|
||||
"""
|
||||
token = _model_call_hooks_dispatched.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_model_call_hooks_dispatched.reset(token)
|
||||
|
||||
|
||||
def model_call_hooks_already_dispatched() -> bool:
|
||||
"""Whether an enclosing caller already dispatched hooks for the current call."""
|
||||
return _model_call_hooks_dispatched.get()
|
||||
|
||||
|
||||
def before_llm_call_reducer(context: LLMCallHookContext, result: object) -> bool:
|
||||
"""Legacy calling convention for ``pre_model_call`` hooks.
|
||||
|
||||
A ``False`` return aborts the call (mapped to :class:`HookAborted`); messages
|
||||
are modified in place, so no payload replacement occurs here.
|
||||
A ``False`` return aborts the call (mapped to :class:`LegacyHookBlocked`);
|
||||
messages are modified in place, so no payload replacement occurs here.
|
||||
"""
|
||||
if result is False:
|
||||
raise HookAborted(reason="before_llm_call hook returned False")
|
||||
raise LegacyHookBlocked(reason="before_llm_call hook returned False")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -598,6 +598,8 @@ class LiteAgent(FlowTrackable, BaseModel):
|
||||
|
||||
def _inject_memory_context(self) -> None:
|
||||
"""Recall relevant memories and append to the system message. No-op if _memory is None."""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if self._memory is None:
|
||||
return
|
||||
query = self._get_last_user_content()
|
||||
@@ -641,9 +643,14 @@ class LiteAgent(FlowTrackable, BaseModel):
|
||||
error=str(e),
|
||||
),
|
||||
)
|
||||
# a deny aborts the run; any other failure degrades to no memory
|
||||
if isinstance(e, HookAborted):
|
||||
raise
|
||||
|
||||
def _save_to_memory(self, output_text: str) -> None:
|
||||
"""Extract discrete memories from the run and remember each. No-op if _memory is None or read-only."""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if self._memory is None or self._memory.read_only:
|
||||
return
|
||||
input_str = self._get_last_user_content() or "User request"
|
||||
@@ -652,6 +659,8 @@ class LiteAgent(FlowTrackable, BaseModel):
|
||||
extracted = self._memory.extract_memories(raw)
|
||||
if extracted:
|
||||
self._memory.remember_many(extracted, agent_role=self.role)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
PRINTER.print(
|
||||
|
||||
@@ -31,10 +31,12 @@ from crewai.events.types.tool_usage_events import (
|
||||
ToolUsageFinishedEvent,
|
||||
ToolUsageStartedEvent,
|
||||
)
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
|
||||
from crewai.llms.base_llm import (
|
||||
BaseLLM,
|
||||
JsonResponseFormat,
|
||||
LLMCallBlockedError,
|
||||
get_current_call_id,
|
||||
llm_call_context,
|
||||
)
|
||||
@@ -660,6 +662,19 @@ class LLM(BaseLLM):
|
||||
if model in AZURE_MODELS:
|
||||
return "azure"
|
||||
|
||||
# Bedrock namespaces Anthropic models as "anthropic.claude-*", optionally
|
||||
# region-prefixed ("us.anthropic.claude-*"). That form also satisfies the
|
||||
# anthropic pattern below, so it has to be settled first.
|
||||
if "anthropic." in model.lower():
|
||||
return "bedrock"
|
||||
|
||||
# Only anthropic and gemini have prefixes unambiguous enough to infer from.
|
||||
# Bedrock matches any model containing a dot (so "gpt-3.5-turbo") and Azure
|
||||
# matches every OpenAI prefix, so both would steal models from openai here.
|
||||
for provider in ("anthropic", "gemini"):
|
||||
if cls._matches_provider_pattern(model, provider):
|
||||
return provider
|
||||
|
||||
return "openai"
|
||||
|
||||
@classmethod
|
||||
@@ -1039,12 +1054,15 @@ class LLM(BaseLLM):
|
||||
|
||||
if not tool_calls or not available_functions:
|
||||
if response_model and self.is_litellm:
|
||||
from crewai.hooks.llm_hooks import model_call_hooks_dispatched
|
||||
|
||||
instructor_instance = InternalInstructor(
|
||||
content=full_response,
|
||||
model=response_model,
|
||||
llm=self,
|
||||
)
|
||||
result = instructor_instance.to_pydantic()
|
||||
with model_call_hooks_dispatched():
|
||||
result = instructor_instance.to_pydantic()
|
||||
structured_response = result.model_dump_json()
|
||||
usage_dict = self._usage_to_dict(usage_info)
|
||||
self._handle_emit_call_events(
|
||||
@@ -1241,6 +1259,7 @@ class LLM(BaseLLM):
|
||||
str: The response text
|
||||
"""
|
||||
if response_model and self.is_litellm:
|
||||
from crewai.hooks.llm_hooks import model_call_hooks_dispatched
|
||||
from crewai.utilities.internal_instructor import InternalInstructor
|
||||
|
||||
messages = params.get("messages", [])
|
||||
@@ -1256,7 +1275,8 @@ class LLM(BaseLLM):
|
||||
model=response_model,
|
||||
llm=self,
|
||||
)
|
||||
result = instructor_instance.to_pydantic()
|
||||
with model_call_hooks_dispatched():
|
||||
result = instructor_instance.to_pydantic()
|
||||
structured_response = result.model_dump_json()
|
||||
self._handle_emit_call_events(
|
||||
response=structured_response,
|
||||
@@ -1396,6 +1416,7 @@ class LLM(BaseLLM):
|
||||
str: The response text
|
||||
"""
|
||||
if response_model and self.is_litellm:
|
||||
from crewai.hooks.llm_hooks import model_call_hooks_dispatched
|
||||
from crewai.utilities.internal_instructor import InternalInstructor
|
||||
|
||||
messages = params.get("messages", [])
|
||||
@@ -1411,7 +1432,8 @@ class LLM(BaseLLM):
|
||||
model=response_model,
|
||||
llm=self,
|
||||
)
|
||||
result = instructor_instance.to_pydantic()
|
||||
with model_call_hooks_dispatched():
|
||||
result = instructor_instance.to_pydantic()
|
||||
structured_response = result.model_dump_json()
|
||||
self._handle_emit_call_events(
|
||||
response=structured_response,
|
||||
@@ -1874,8 +1896,11 @@ class LLM(BaseLLM):
|
||||
msg_role: Literal["assistant"] = "assistant"
|
||||
message["role"] = msg_role
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(messages, from_agent):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
try:
|
||||
self._invoke_before_llm_call_hooks(messages, from_agent)
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
|
||||
with suppress_warnings():
|
||||
if callbacks and len(callbacks) > 0:
|
||||
@@ -2016,6 +2041,12 @@ class LLM(BaseLLM):
|
||||
msg_role: Literal["assistant"] = "assistant"
|
||||
message["role"] = msg_role
|
||||
|
||||
try:
|
||||
self._invoke_before_llm_call_hooks(messages, from_agent)
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
|
||||
with suppress_warnings():
|
||||
if callbacks and len(callbacks) > 0:
|
||||
self.set_callbacks(callbacks)
|
||||
@@ -2360,6 +2391,14 @@ class LLM(BaseLLM):
|
||||
):
|
||||
return [*messages, {"role": "user", "content": ""}] # type: ignore[list-item]
|
||||
|
||||
# Handle Gemini models - they require the last message to not be 'assistant'
|
||||
if (
|
||||
("gemini" in self.model.lower() or "google" in self.model.lower())
|
||||
and messages
|
||||
and messages[-1]["role"] == "assistant"
|
||||
):
|
||||
return [*messages, {"role": "user", "content": "Please continue."}] # type: ignore[list-item]
|
||||
|
||||
if not self.is_anthropic:
|
||||
return messages # type: ignore[return-value]
|
||||
|
||||
|
||||
@@ -72,6 +72,15 @@ class JsonResponseFormat(TypedDict):
|
||||
type: Literal["json_object"]
|
||||
|
||||
|
||||
class LLMCallBlockedError(ValueError):
|
||||
"""A ``before_llm_call`` hook blocked the call by returning ``False``.
|
||||
|
||||
A ``ValueError`` so the fail-open handlers around internal model calls keep
|
||||
absorbing it, and its own type so a provider can report it as the decision
|
||||
it is instead of letting it read as a provider outage.
|
||||
"""
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_WINDOW_SIZE: Final[int] = 4096
|
||||
DEFAULT_SUPPORTS_STOP_WORDS: Final[bool] = True
|
||||
_JSON_EXTRACTION_PATTERN: Final[re.Pattern[str]] = re.compile(r"\{.*}", re.DOTALL)
|
||||
@@ -660,6 +669,28 @@ class BaseLLM(BaseModel, ABC):
|
||||
),
|
||||
)
|
||||
|
||||
def _emit_call_denied_event(
|
||||
self,
|
||||
denial: Exception,
|
||||
from_task: Task | None = None,
|
||||
from_agent: BaseAgent | None = None,
|
||||
) -> None:
|
||||
"""Report a hook deny as a deny rather than as a provider failure.
|
||||
|
||||
The call still owes its started event a terminal one, so the failed event
|
||||
is emitted — with a message that names the decision instead of blaming
|
||||
the provider for an outage that never happened.
|
||||
"""
|
||||
from crewai.hooks.dispatch import source_name
|
||||
|
||||
source = source_name(getattr(denial, "source", None))
|
||||
reason = getattr(denial, "reason", str(denial))
|
||||
message = f"LLM call denied by {source or 'hook'}: {reason}"
|
||||
logging.warning(message)
|
||||
self._emit_call_failed_event(
|
||||
error=message, from_task=from_task, from_agent=from_agent
|
||||
)
|
||||
|
||||
def _emit_stream_chunk_event(
|
||||
self,
|
||||
chunk: str,
|
||||
@@ -990,43 +1021,51 @@ class BaseLLM(BaseModel, ABC):
|
||||
messages: list[LLMMessage],
|
||||
from_agent: BaseAgent | None = None,
|
||||
) -> bool:
|
||||
"""Invoke before_llm_call hooks for direct LLM calls (no agent context).
|
||||
"""Invoke before_llm_call hooks for an LLM call reaching the provider.
|
||||
|
||||
This method should be called by native provider implementations before
|
||||
making the actual LLM call when from_agent is None (direct calls).
|
||||
making the actual LLM call. It no-ops when an enclosing caller — the
|
||||
executor — already dispatched the hooks for this same call.
|
||||
|
||||
Args:
|
||||
messages: The messages being sent to the LLM
|
||||
from_agent: The agent making the call (None for direct calls)
|
||||
from_agent: The agent making the call, when there is one
|
||||
|
||||
Returns:
|
||||
True if LLM call should proceed, False if blocked by hook
|
||||
True, so a provider may still guard the call with ``if not ...``.
|
||||
A block is raised, never returned.
|
||||
|
||||
Raises:
|
||||
HookAborted: If a hook raised it. The deny reaches the caller intact
|
||||
instead of being flattened into a provider-style error.
|
||||
LLMCallBlockedError: If a legacy hook blocked the call by returning
|
||||
``False``. A ``ValueError``, so the fail-open handlers around
|
||||
internal model calls keep absorbing it.
|
||||
|
||||
Example:
|
||||
>>> # In a native provider's call() method:
|
||||
>>> if from_agent is None and not self._invoke_before_llm_call_hooks(
|
||||
... messages, from_agent
|
||||
... ):
|
||||
... raise ValueError("LLM call blocked by hook")
|
||||
>>> self._invoke_before_llm_call_hooks(messages, from_agent)
|
||||
"""
|
||||
if from_agent is not None:
|
||||
return True
|
||||
|
||||
from crewai_core.printer import PRINTER
|
||||
|
||||
from crewai.hooks.dispatch import HookAborted, InterceptionPoint, dispatch
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
from crewai.hooks.llm_hooks import (
|
||||
LLMCallHookContext,
|
||||
LegacyHookBlocked,
|
||||
before_llm_call_reducer,
|
||||
model_call_hooks_already_dispatched,
|
||||
)
|
||||
|
||||
if model_call_hooks_already_dispatched():
|
||||
return True
|
||||
|
||||
# No early global-list guard: dispatch resolves global + execution-scoped
|
||||
# hooks and has its own no-op fast path, so scoped hooks still run here.
|
||||
hook_context = LLMCallHookContext(
|
||||
executor=None,
|
||||
messages=messages,
|
||||
llm=self,
|
||||
agent=None,
|
||||
agent=from_agent,
|
||||
task=None,
|
||||
crew=None,
|
||||
)
|
||||
@@ -1037,12 +1076,14 @@ class BaseLLM(BaseModel, ABC):
|
||||
hook_context,
|
||||
reducer=before_llm_call_reducer,
|
||||
)
|
||||
except HookAborted:
|
||||
except LegacyHookBlocked as blocked:
|
||||
PRINTER.print(
|
||||
content="LLM call blocked by before_llm_call hook",
|
||||
color="yellow",
|
||||
)
|
||||
return False
|
||||
raise LLMCallBlockedError(
|
||||
"LLM call blocked by before_llm_call hook"
|
||||
) from blocked
|
||||
|
||||
return True
|
||||
|
||||
@@ -1052,42 +1093,44 @@ class BaseLLM(BaseModel, ABC):
|
||||
response: str,
|
||||
from_agent: BaseAgent | None = None,
|
||||
) -> str:
|
||||
"""Invoke after_llm_call hooks for direct LLM calls (no agent context).
|
||||
"""Invoke after_llm_call hooks for an LLM call that reached the provider.
|
||||
|
||||
This method should be called by native provider implementations after
|
||||
receiving the LLM response when from_agent is None (direct calls).
|
||||
receiving the LLM response. It no-ops when an enclosing caller — the
|
||||
executor — already dispatched the hooks for this same call.
|
||||
|
||||
Args:
|
||||
messages: The messages that were sent to the LLM
|
||||
response: The response from the LLM
|
||||
from_agent: The agent that made the call (None for direct calls)
|
||||
from_agent: The agent that made the call, when there is one
|
||||
|
||||
Returns:
|
||||
The potentially modified response string
|
||||
|
||||
Example:
|
||||
>>> # In a native provider's call() method:
|
||||
>>> if from_agent is None and isinstance(result, str):
|
||||
>>> if isinstance(result, str):
|
||||
... result = self._invoke_after_llm_call_hooks(
|
||||
... messages, result, from_agent
|
||||
... )
|
||||
"""
|
||||
if from_agent is not None or not isinstance(response, str):
|
||||
return response
|
||||
|
||||
from crewai.hooks.dispatch import InterceptionPoint, dispatch
|
||||
from crewai.hooks.llm_hooks import (
|
||||
LLMCallHookContext,
|
||||
after_llm_call_reducer,
|
||||
model_call_hooks_already_dispatched,
|
||||
)
|
||||
|
||||
if model_call_hooks_already_dispatched() or not isinstance(response, str):
|
||||
return response
|
||||
|
||||
# No early global-list guard: dispatch resolves global + execution-scoped
|
||||
# hooks and has its own no-op fast path, so scoped hooks still run here.
|
||||
hook_context = LLMCallHookContext(
|
||||
executor=None,
|
||||
messages=messages,
|
||||
llm=self,
|
||||
agent=None,
|
||||
agent=from_agent,
|
||||
task=None,
|
||||
crew=None,
|
||||
response=response,
|
||||
|
||||
@@ -8,7 +8,13 @@ from typing import Any, Final, Literal, Protocol, TypeGuard, TypedDict, cast
|
||||
from pydantic import BaseModel, PrivateAttr, model_validator
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM, JsonResponseFormat, llm_call_context
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms.base_llm import (
|
||||
BaseLLM,
|
||||
JsonResponseFormat,
|
||||
LLMCallBlockedError,
|
||||
llm_call_context,
|
||||
)
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.llms.hooks.transport import AsyncHTTPTransport, HTTPTransport
|
||||
from crewai.llms.providers.utils.common import safe_tool_conversion
|
||||
@@ -75,6 +81,11 @@ def _default_max_tokens_for_model(model: str) -> int:
|
||||
|
||||
NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
tuple[
|
||||
Literal["claude-fable-5"],
|
||||
Literal["claude-opus-5"],
|
||||
Literal["claude-sonnet-5"],
|
||||
Literal["claude-opus-4-8"],
|
||||
Literal["claude-opus-4.8"],
|
||||
Literal["claude-sonnet-4-5"],
|
||||
Literal["claude-sonnet-4.5"],
|
||||
Literal["claude-opus-4-5"],
|
||||
@@ -83,6 +94,11 @@ NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
Literal["claude-haiku-4.5"],
|
||||
]
|
||||
] = (
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-opus-4-5",
|
||||
@@ -95,9 +111,9 @@ NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
def _supports_native_structured_outputs(model: str) -> bool:
|
||||
"""Check if the model supports native structured outputs.
|
||||
|
||||
Native structured outputs are only available for Claude 4.5 models
|
||||
(Sonnet 4.5, Opus 4.5, Haiku 4.5).
|
||||
Other models require the tool-based fallback approach.
|
||||
Covers Claude Fable 5, Opus 5, Sonnet 5, Opus 4.8 and the 4.5-era models
|
||||
(Sonnet 4.5, Opus 4.5, Haiku 4.5). Other models require the tool-based
|
||||
fallback approach.
|
||||
|
||||
Args:
|
||||
model: The model name/identifier.
|
||||
@@ -401,10 +417,7 @@ class AnthropicCompletion(BaseLLM):
|
||||
self._format_messages_for_anthropic(messages)
|
||||
)
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(
|
||||
formatted_messages, from_agent
|
||||
):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
completion_params = self._prepare_completion_params(
|
||||
formatted_messages, system_message, tools, available_functions
|
||||
@@ -429,6 +442,9 @@ class AnthropicCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Anthropic API call failed: {e!s}"
|
||||
logging.error(error_msg)
|
||||
@@ -476,6 +492,8 @@ class AnthropicCompletion(BaseLLM):
|
||||
self._format_messages_for_anthropic(messages)
|
||||
)
|
||||
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
completion_params = self._prepare_completion_params(
|
||||
formatted_messages, system_message, tools, available_functions
|
||||
)
|
||||
@@ -499,6 +517,9 @@ class AnthropicCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Anthropic API call failed: {e!s}"
|
||||
logging.error(error_msg)
|
||||
|
||||
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
|
||||
from pydantic import BaseModel, PrivateAttr, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.utilities.agent_utils import is_context_length_exceeded
|
||||
@@ -42,7 +43,12 @@ try:
|
||||
)
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM, call_stream_override, llm_call_context
|
||||
from crewai.llms.base_llm import (
|
||||
BaseLLM,
|
||||
LLMCallBlockedError,
|
||||
call_stream_override,
|
||||
llm_call_context,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
@@ -521,10 +527,7 @@ class AzureCompletion(BaseLLM):
|
||||
|
||||
formatted_messages = self._format_messages_for_azure(messages)
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(
|
||||
formatted_messages, from_agent
|
||||
):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
completion_params = self._prepare_completion_params(
|
||||
formatted_messages, tools, effective_response_model
|
||||
@@ -547,6 +550,9 @@ class AzureCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
return self._handle_api_error(e, from_task, from_agent) # type: ignore[func-returns-value]
|
||||
|
||||
@@ -603,6 +609,8 @@ class AzureCompletion(BaseLLM):
|
||||
|
||||
formatted_messages = self._format_messages_for_azure(messages)
|
||||
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
completion_params = self._prepare_completion_params(
|
||||
formatted_messages, tools, effective_response_model
|
||||
)
|
||||
@@ -624,6 +632,9 @@ class AzureCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
self._handle_api_error(e, from_task, from_agent)
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ from pydantic import BaseModel, PrivateAttr, model_validator
|
||||
from typing_extensions import Required
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM, llm_call_context
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms.base_llm import BaseLLM, LLMCallBlockedError, llm_call_context
|
||||
from crewai.llms.providers.utils.common import safe_tool_conversion
|
||||
from crewai.utilities.agent_utils import is_context_length_exceeded
|
||||
from crewai.utilities.exceptions.context_window_exceeding_exception import (
|
||||
@@ -377,10 +378,7 @@ class BedrockCompletion(BaseLLM):
|
||||
messages
|
||||
)
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(
|
||||
formatted_messages, from_agent
|
||||
):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
body: BedrockConverseRequestBody = {
|
||||
"inferenceConfig": self._get_inference_config(),
|
||||
@@ -447,6 +445,9 @@ class BedrockCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
if is_context_length_exceeded(e):
|
||||
logging.error(f"Context window exceeded: {e}")
|
||||
@@ -510,6 +511,8 @@ class BedrockCompletion(BaseLLM):
|
||||
messages
|
||||
)
|
||||
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
body: BedrockConverseRequestBody = {
|
||||
"inferenceConfig": self._get_inference_config(),
|
||||
}
|
||||
@@ -575,6 +578,9 @@ class BedrockCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
if is_context_length_exceeded(e):
|
||||
logging.error(f"Context window exceeded: {e}")
|
||||
|
||||
@@ -10,7 +10,8 @@ from typing import Any, Literal, cast
|
||||
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.llms.base_llm import BaseLLM, llm_call_context
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms.base_llm import BaseLLM, LLMCallBlockedError, llm_call_context
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.utilities.agent_utils import is_context_length_exceeded
|
||||
from crewai.utilities.exceptions.context_window_exceeding_exception import (
|
||||
@@ -313,10 +314,7 @@ class GeminiCompletion(BaseLLM):
|
||||
|
||||
messages_for_hooks = self._convert_contents_to_dict(formatted_content)
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(
|
||||
messages_for_hooks, from_agent
|
||||
):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
self._invoke_before_llm_call_hooks(messages_for_hooks, from_agent)
|
||||
|
||||
config = self._prepare_generation_config(
|
||||
system_instruction, tools, effective_response_model
|
||||
@@ -341,6 +339,9 @@ class GeminiCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except APIError as e:
|
||||
error_msg = f"Google Gemini API error: {e.code} - {e.message}"
|
||||
logging.error(error_msg)
|
||||
@@ -397,6 +398,10 @@ class GeminiCompletion(BaseLLM):
|
||||
self._format_messages_for_gemini(messages)
|
||||
)
|
||||
|
||||
messages_for_hooks = self._convert_contents_to_dict(formatted_content)
|
||||
|
||||
self._invoke_before_llm_call_hooks(messages_for_hooks, from_agent)
|
||||
|
||||
config = self._prepare_generation_config(
|
||||
system_instruction, tools, effective_response_model
|
||||
)
|
||||
@@ -420,6 +425,9 @@ class GeminiCompletion(BaseLLM):
|
||||
effective_response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except APIError as e:
|
||||
error_msg = f"Google Gemini API error: {e.code} - {e.message}"
|
||||
logging.error(error_msg)
|
||||
@@ -554,12 +562,19 @@ class GeminiCompletion(BaseLLM):
|
||||
- System messages are separate system_instruction
|
||||
- Content is organized as Content objects with Parts
|
||||
- Roles are 'user' and 'model' (not 'assistant')
|
||||
- History may not end on a model turn; a "Please continue." user turn
|
||||
is appended when it does
|
||||
|
||||
Args:
|
||||
messages: Input messages
|
||||
|
||||
Returns:
|
||||
Tuple of (formatted_contents, system_instruction)
|
||||
|
||||
Raises:
|
||||
ValueError: If the history ends on a model turn with an unresolved
|
||||
function call, which requires a function response rather than a
|
||||
continuation prompt.
|
||||
"""
|
||||
base_formatted = super()._format_messages(messages)
|
||||
|
||||
@@ -672,6 +687,23 @@ class GeminiCompletion(BaseLLM):
|
||||
gemini_content = types.Content(role=gemini_role, parts=parts)
|
||||
contents.append(gemini_content)
|
||||
|
||||
if contents and contents[-1].role == "model":
|
||||
# Gemini's generateContent API rejects a request whose history ends
|
||||
# on a model turn (agent loops can produce this, e.g. after
|
||||
# max-iteration handling or a guardrail retry).
|
||||
last_parts = contents[-1].parts or []
|
||||
if any(part.function_call for part in last_parts):
|
||||
raise ValueError(
|
||||
"Gemini message history ends on an unresolved function call "
|
||||
"-- a function response must be provided before calling the "
|
||||
"model again."
|
||||
)
|
||||
contents.append(
|
||||
types.Content(
|
||||
role="user", parts=[types.Part.from_text(text="Please continue.")]
|
||||
)
|
||||
)
|
||||
|
||||
return contents, system_instruction
|
||||
|
||||
def _validate_and_emit_structured_output(
|
||||
|
||||
@@ -36,8 +36,14 @@ from openai.types.responses import (
|
||||
from pydantic import BaseModel, PrivateAttr, model_validator
|
||||
|
||||
from crewai.events.types.llm_events import LLMCallType
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
from crewai.llms._finish_reason_utils import extract_choices_finish_reason_and_id
|
||||
from crewai.llms.base_llm import BaseLLM, JsonResponseFormat, llm_call_context
|
||||
from crewai.llms.base_llm import (
|
||||
BaseLLM,
|
||||
JsonResponseFormat,
|
||||
LLMCallBlockedError,
|
||||
llm_call_context,
|
||||
)
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.llms.hooks.transport import AsyncHTTPTransport, HTTPTransport
|
||||
from crewai.llms.providers.utils.common import safe_tool_conversion
|
||||
@@ -462,10 +468,7 @@ class OpenAICompletion(BaseLLM):
|
||||
|
||||
formatted_messages = self._format_messages(messages)
|
||||
|
||||
if not self._invoke_before_llm_call_hooks(
|
||||
formatted_messages, from_agent
|
||||
):
|
||||
raise ValueError("LLM call blocked by before_llm_call hook")
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
if self._effective_api() == "responses":
|
||||
return self._call_responses(
|
||||
@@ -486,6 +489,9 @@ class OpenAICompletion(BaseLLM):
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"OpenAI API call failed: {e!s}"
|
||||
logging.error(error_msg)
|
||||
@@ -600,6 +606,8 @@ class OpenAICompletion(BaseLLM):
|
||||
|
||||
formatted_messages = self._format_messages(messages)
|
||||
|
||||
self._invoke_before_llm_call_hooks(formatted_messages, from_agent)
|
||||
|
||||
if self._effective_api() == "responses":
|
||||
return await self._acall_responses(
|
||||
messages=formatted_messages,
|
||||
@@ -619,6 +627,9 @@ class OpenAICompletion(BaseLLM):
|
||||
response_model=response_model,
|
||||
)
|
||||
|
||||
except (HookAborted, LLMCallBlockedError) as e:
|
||||
self._emit_call_denied_event(e, from_task, from_agent)
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"OpenAI API call failed: {e!s}"
|
||||
logging.error(error_msg)
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
@@ -91,23 +92,39 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderConfig] = {
|
||||
),
|
||||
}
|
||||
|
||||
_OLLAMA_DEFAULT_PORT = 11434
|
||||
|
||||
|
||||
def _normalize_ollama_base_url(base_url: str) -> str:
|
||||
"""Normalize Ollama base URL to ensure it ends with /v1.
|
||||
"""Normalize an Ollama base URL into a full OpenAI-compatible endpoint.
|
||||
|
||||
Ollama uses OLLAMA_HOST which may not include the /v1 suffix,
|
||||
but the OpenAI-compatible endpoint requires it.
|
||||
``OLLAMA_HOST`` follows Ollama's own convention and may be a bare host
|
||||
(``0.0.0.0``), a ``host:port`` pair (``127.0.0.1:11434``), or a full URL.
|
||||
Whichever parts are missing are filled in: ``http://`` when no scheme is
|
||||
given, the default Ollama port when none is given and the scheme is
|
||||
``http`` (``https`` implies 443), and the ``/v1`` suffix that the
|
||||
OpenAI-compatible endpoint requires.
|
||||
|
||||
Args:
|
||||
base_url: The base URL, potentially without /v1 suffix.
|
||||
base_url: The base URL, potentially missing scheme, port or /v1.
|
||||
|
||||
Returns:
|
||||
The base URL with /v1 suffix if needed.
|
||||
A fully-qualified base URL ending in /v1.
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if not base_url.endswith("/v1"):
|
||||
return f"{base_url}/v1"
|
||||
return base_url
|
||||
if "://" not in base_url:
|
||||
base_url = f"http://{base_url}"
|
||||
|
||||
parts = urlsplit(base_url)
|
||||
|
||||
netloc = parts.netloc
|
||||
if parts.scheme == "http" and parts.port is None:
|
||||
netloc = f"{netloc}:{_OLLAMA_DEFAULT_PORT}"
|
||||
|
||||
path = parts.path.rstrip("/")
|
||||
if not path.endswith("/v1"):
|
||||
path = f"{path}/v1"
|
||||
|
||||
return urlunsplit((parts.scheme, netloc, path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
class OpenAICompatibleCompletion(OpenAICompletion):
|
||||
|
||||
@@ -168,6 +168,8 @@ def extract_memories_from_content(content: str, llm: Any) -> list[str]:
|
||||
Returns:
|
||||
List of short, self-contained memory statements (or [content] on failure).
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if not (content or "").strip():
|
||||
return []
|
||||
user = _get_prompt("extract_memories_user").format(content=content)
|
||||
@@ -188,6 +190,8 @@ def extract_memories_from_content(content: str, llm: Any) -> list[str]:
|
||||
data = json.loads(response)
|
||||
return ExtractedMemories.model_validate(data).memories
|
||||
return ExtractedMemories.model_validate(response).memories
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Memory extraction failed, storing full content as single memory: %s",
|
||||
@@ -216,6 +220,8 @@ def analyze_query(
|
||||
Returns:
|
||||
QueryAnalysis with keywords, suggested_scopes, complexity, recall_queries, time_filter.
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
scope_desc = ""
|
||||
if scope_info:
|
||||
scope_desc = f"Current scope has {scope_info.record_count} records, categories: {scope_info.categories}"
|
||||
@@ -241,6 +247,8 @@ def analyze_query(
|
||||
data = json.loads(response)
|
||||
return QueryAnalysis.model_validate(data)
|
||||
return QueryAnalysis.model_validate(response)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Query analysis failed, using defaults (complexity=simple): %s",
|
||||
@@ -284,6 +292,8 @@ def analyze_for_save(
|
||||
Returns:
|
||||
MemoryAnalysis with suggested_scope, categories, importance, extracted_metadata.
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
user = _get_prompt("save_user").format(
|
||||
content=content,
|
||||
existing_scopes=existing_scopes or ["/"],
|
||||
@@ -306,6 +316,8 @@ def analyze_for_save(
|
||||
data = json.loads(response)
|
||||
return MemoryAnalysis.model_validate(data)
|
||||
return MemoryAnalysis.model_validate(response)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Memory save analysis failed, using defaults: %s",
|
||||
@@ -336,6 +348,8 @@ def analyze_for_consolidation(
|
||||
Returns:
|
||||
ConsolidationPlan with actions per record and whether to insert the new content.
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
if not existing_records:
|
||||
return ConsolidationPlan(actions=[], insert_new=True)
|
||||
records_lines: list[str] = []
|
||||
@@ -366,6 +380,8 @@ def analyze_for_consolidation(
|
||||
data = json.loads(response)
|
||||
return ConsolidationPlan.model_validate(data)
|
||||
return ConsolidationPlan.model_validate(response)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Consolidation analysis failed, defaulting to insert: %s",
|
||||
|
||||
@@ -26,6 +26,7 @@ def _ensure_memory_kind(value: Any) -> Any:
|
||||
Pass-through for non-dict values (instances, ``bool``, ``None``).
|
||||
"""
|
||||
if isinstance(value, dict) and "memory_kind" not in value:
|
||||
value = dict(value)
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
@@ -55,6 +56,7 @@ class MemoryScope(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemoryScope, got {type(data).__name__}")
|
||||
data = dict(data)
|
||||
memory = data.pop("memory", None)
|
||||
instance: MemoryScope = handler(data)
|
||||
if memory is not None:
|
||||
@@ -245,6 +247,7 @@ class MemorySlice(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemorySlice, got {type(data).__name__}")
|
||||
data = dict(data)
|
||||
memory = data.pop("memory", None)
|
||||
data["scopes"] = [s.rstrip("/") or "/" for s in data.get("scopes", [])]
|
||||
instance: MemorySlice = handler(data)
|
||||
|
||||
@@ -296,6 +296,8 @@ class RecallFlow(Flow[RecallState]):
|
||||
|
||||
Decrements the exploration budget so the loop terminates.
|
||||
"""
|
||||
from crewai.hooks.dispatch import HookAborted
|
||||
|
||||
self.state.exploration_budget -= 1
|
||||
|
||||
enhanced = []
|
||||
@@ -321,6 +323,8 @@ class RecallFlow(Flow[RecallState]):
|
||||
"results": finding["results"],
|
||||
}
|
||||
)
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception:
|
||||
enhanced.append(
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user