Compare commits

..

25 Commits

Author SHA1 Message Date
Iris Clawd
8cc6e5d225 fix(deps): bump gitpython to >=3.1.60 for PYSEC-2026-3785–3788
Raise gitpython floor from >=3.1.58 to >=3.1.60 in both the workspace
override and crewai-tools[github] to clear pip-audit findings on
PYSEC-2026-3785 through PYSEC-2026-3788 (.gitmodules include disclosure,
config-injection RCE, --separate-git-dir clone, Repo.blame file read).

Drop the gitpython exclude-newer-package cutoff (3.1.60+ is older than
the global 3-day window). Lock resolves to 3.1.61.

Co-authored-by: Vidit Ostwal <viditostwal@gmail.com>
2026-09-04 08:12:43 +00:00
Havel Cyrus
92eb5f9183 fix: append trailing user turn in native Gemini provider (#6973)
* fix: append trailing user turn in native Gemini provider

GeminiCompletion._format_messages_for_gemini maps assistant messages
to Gemini's 'model' role but never guards against the resulting
contents list ending on a model turn. CrewAI's own agent loop (max
iterations, guardrail retries) can produce exactly that history, and
Gemini's generateContent API rejects it with 400 'Requests ending
with a model turn are not supported'.

Mirrors the existing Mistral/Ollama guard in
LLM._format_messages_for_provider, which never applies to Gemini
since gemini/google model strings resolve to this native provider
instead of the LiteLLM fallback path.

Fixes #6972

* fix: append trailing user turn for Gemini on the LiteLLM fallback path

LLM._format_messages_for_provider already guards Mistral/Ollama
against a trailing assistant turn, but Gemini models routed through
the LiteLLM fallback (no google-genai installed, or a model name not
recognized as native) had no equivalent guard. litellm's own
Vertex/Gemini transformation doesn't handle this either, so the
request reaches Gemini's generateContent API unguarded and 400s.

Complements the native-provider fix in GeminiCompletion, covering
both dispatch paths.

* fix: don't append text turn after unresolved Gemini function call

Address CodeRabbit review on #6973: appending a plain 'Please
continue.' user turn after a trailing model turn that contains an
unresolved function_call violates Gemini's function-calling protocol
-- it requires a matching functionResponse, not free text. Raise a
targeted error instead so the caller notices rather than silently
sending a malformed follow-up.

Also strengthens the native-provider formatting tests to assert exact
role sequence and text content (not just the last role), per review,
and adds a regression test for the unresolved-function-call case.

* fix: guard None parts when checking Gemini history for unresolved function call

contents[-1].parts is typed list[Part] | None; iterating it directly
failed mypy (union-attr) on 3.10-3.13. Narrow to [] before the any()
check and document the ValueError in the docstring.

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-03 16:26:44 +05:30
Parthiban Sivakumar
c90337ba5a fix(llms): normalize scheme and port in Ollama base URL (#7206)
* fix(llms): normalize scheme and port in Ollama base URL

OLLAMA_HOST follows Ollama's own convention and may be a bare host
("0.0.0.0") or a host:port pair ("127.0.0.1:11434") rather than a full
URL. _normalize_ollama_base_url only appended "/v1", so those values
produced invalid base URLs such as "0.0.0.0/v1", and every request
failed with the misleading error "Failed to connect to OpenAI API:
Connection error." - confusing, since no OpenAI model was requested.

Fill in the missing parts the way Ollama's own client does: prepend
http:// when no scheme is present, append the default port 11434 when
none is present and the scheme is http (https implies 443), then append
the /v1 suffix the OpenAI-compatible endpoint requires.

Six of nine realistic OLLAMA_HOST forms were affected, including
127.0.0.1:11434, which is Ollama's documented default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(llms): strip only the parsed path when normalizing Ollama base URL

Stripping trailing slashes from the whole URL before parsing corrupted
inputs that carry a query or fragment. "http://ollama/?tenant=acme" kept
a "/" path and produced a doubled "//v1", and a query or fragment ending
in "/" silently lost that character.

Parse first, then rstrip only parts.path. Adds regression tests for a
root path alongside a query and for a query value ending in "/".

Reported by CodeRabbit on #7206.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-03 16:05:56 +05:30
Vidit Ostwal
3d72c707d5 chore(ci): ignore unpatched nltk GHSA-8mgp-746c-j5xp (#7215)
* chore(ci): ignore unpatched nltk GHSA-8mgp-746c-j5xp

No patched PyPI release exists beyond 3.10.3. nltk is transitive via
crewai-tools[xml] -> unstructured; CrewAI does not call the vulnerable
model-artifact APIs.

Co-authored-by: Vidit Ostwal <Vidit-Ostwal@users.noreply.github.com>

* chore(ci): note dropping nltk GHSA ignore on the next bump

Leave an explicit TODO beside the ignore so GHSA-8mgp-746c-j5xp is
removed when nltk moves past the unpatched 3.10.3 floor.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Vidit Ostwal <Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:04:20 -07:00
Zhewen Tan
98799a3b09 fix(memory): preserve reusable scope configs (#7068)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 19:35:30 +05:30
Vidit Ostwal
1cef70de52 fix: bump pypdf to 6.16.2 for GHSA-jp53-mhqp-8xcg (#7200)
* fix: bump pypdf to 6.16.2 for GHSA-jp53-mhqp-8xcg

pypdf 6.15.0 fails pip-audit on three moderate DoS advisories; 6.16.1+ patches them.

* chore: keep existing uv.lock environment markers

A full uv lock refresh rewrote unrelated dependency markers; restore them so the pypdf bump stays isolated.

* chore: drop unused pypdf exclude-newer-package in crewai-files

~=6.16.1 plus the global 3-day cutoff already admits 6.16.2.

* chore: drop pypdf from exclude-newer-package

6.16.2 is already older than the global 3-day cutoff; the version floor is enough.
2026-09-02 10:38:33 -03:00
Fang Kaiqi
b608a3595c docs: remove CodeInterpreterTool from AI/ML overview examples (#7100)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:31:18 +05:30
Fang Kaiqi
f5db5a1788 docs: point prompt-template link at its current path (#7101)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:21:40 +05:30
Vinicius Brasil
968c3065d3 Add Clipper integrations client (#7196)
* Add Clipper integrations client

Implement the internal Clipper discovery and execution contract with
deployment authentication and normalized results. Keep
CrewAIPlatformTools on LegacyClient until the new path is ready for
selection.

* Allow Clipper requests without deployment instances

Local and self-hosted CrewAI executions can have a valid integration
token without a deployment instance UUID. Send the deployment header
when it is available and allow Clipper to attribute other executions to
the organization.

* fixup! Allow Clipper requests without deployment instances

* fixup! Add Clipper integrations client
2026-09-01 14:11:43 -07:00
Vidit Ostwal
818f2624e8 [OSS-149] Accept 1/yes/on on telemetry disable flags (#7185)
* fix(telemetry): accept 1/yes/on on disable flags

CREWAI_DISABLE_TELEMETRY=1 was ignored because the gate only matched true, so telemetry stayed on with no warning.

* fix(telemetry): warn once on unrecognized disable values

Stop repeating the same invalid-flag warning on every telemetry check, and drop the undocumented CREWAI_DISABLE_TRACKING alias from docs.

---------

Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-09-01 11:41:20 -07:00
Vidit Ostwal
8e46205619 [OSS-151] Fail closed when human-feedback emit cannot classify (#7188)
* fix(flow): resolve @human_feedback emit LLM from the project model

Omitting llm= no longer hardcodes OpenAI. Collapse and learn resolve through create_llm so MODEL / MODEL_NAME / OPENAI_MODEL_NAME win, then DEFAULT_LLM_MODEL.

* fix(flow): fail closed when human-feedback collapse cannot classify

Stop routing to emit[0] when the collapse LLM cannot be called or its response does not match an outcome. Empty skip still uses default_outcome.

* refactor(flow): extract human-feedback collapse matching helpers

Move match/require outcome helpers out of _collapse_to_outcome so the classify path stays flat.

* refactor(flow): catch only LLM call failures in collapse

Keep HumanFeedbackCollapseError from matching outside the call try so it is raised once and does not trigger a second prompt.

* fix(flow): treat non-object JSON as raw collapse text

Avoid AttributeError when the collapse LLM returns JSON that is not an object.
2026-09-01 17:25:07 +00:00
Thiago Moretto
1bc2e0722d feat(flows): add now() to the CEL expression environment (#7194)
* feat(flows): add now() to the CEL expression environment

CEL expressions in flow definitions had no way to produce the current
date: the environment was built bare, so date-dependent flows failed at
runtime. Register a now() function that returns the current UTC time as
a CEL timestamp. The value is frozen once per kickoff so every
expression in a run sees the same instant, even across midnight.

Standard CEL covers formatting from there: string(now()),
now().getFullYear(), now() - duration('24h').

* chore(flows): drop redundant comment on _cel_now

* refactor(flows): derive CEL env and functions from one registry

A function now lives in one _CelFunctionSpec entry: its annotation for
compile and its implementation factory for evaluate, so the two cannot
drift. Run-scoped values move into _CelRunContext; adding one is a
field, not a new parameter through every helper signature.

* chore(flows): drop _CelRunContext docstring

* fix(flows): freeze a fresh cel now() on human-feedback resume

resume_async never passes through kickoff_async, so a flow restored
with from_pending() had no frozen instant and now() fell back to live
wall-clock per expression. Freeze a fresh instant at resume instead of
persisting the kickoff one: a flow can pause on feedback for days, and
expressions after resume must see today.
2026-09-01 17:05:47 +00:00
Vinicius Brasil
48cc5d4e5e Decouple platform tools from the integrations API (#7180)
* Decouple platform tools from the integrations API

Define normalized selector and tool data so platform tool creation does
not depend on the legacy API response shape. This contract makes the
legacy client easier to replace later.

- Move action discovery and response normalization into LegacyClient.
- Pass ToolInfo from discovery through tool creation and execution.
- Replace the builder flow with direct factory orchestration.
- Preserve app, action, and connection data in immutable models.
- Build sanitized tool names from the full tool identity.
- Preserve legacy request, SSL, and failure behavior with contract tests.

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API
2026-09-01 16:07:08 +00:00
Lorenze Jay
917b9df6d7 Validate JSON crews in project environments (#7171)
* Validate JSON crews in project environments

* fix(cli): address standalone deploy review feedback

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-01 16:00:59 +00:00
João Moura
ec53d6f534 fix(llms): native structured outputs for current claude models, and snowflake CVE floor (#7182)
* fix(llms): let current claude models use native structured outputs

NATIVE_STRUCTURED_OUTPUT_MODELS only listed 4.5-era prefixes, so Opus 5,
Sonnet 5, Fable 5 and Opus 4.8 fell through to the forced-tool-call
fallback. That path also overwrites params["tools"], so a call combining
tools with a response_model silently lost the caller's tools.

_infer_provider_from_model documented a pattern-matching fallback it never
performed, so a Claude release newer than the constants list resolved to
"openai". Bedrock ('.' in model) and Azure (every OpenAI prefix) are left
out of that fallback because they would capture gpt-* models.

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

* fix(llms): route bedrock-namespaced anthropic ids to bedrock

"anthropic.claude-*" is Bedrock's namespace, not the Anthropic API, and it
satisfies the anthropic prefix pattern. Settle it before the pattern loop so
an unlisted Bedrock id picks BedrockCompletion. The region-prefixed form
("us.anthropic.claude-*") was resolving to openai, so this repairs that too.

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

* fix(deps): raise snowflake-connector-python floor for CVE-2026-15925

GHSA-5cc2-282f-jjq2 (CRITICAL): the connector does not verify TLS hostnames,
so a network attacker can impersonate the Snowflake endpoint. Fixed in 4.7.1.

crewai-tools[snowflake] declares "snowflake-connector-python>=3.12.4", which
the lock had resolved to 4.6.0. Following the existing convention, the security
floor goes in [tool.uv] override-dependencies rather than the source
declaration, matching how cryptography is handled.

Relocking also refreshes numpy/humanfriendly/nvidia environment markers, which
re-resolution under the relative exclude-newer window produces regardless of
this change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:01:22 +05:30
Vinicius Brasil
381fef73be Add injectable client for CrewAI platform tools (#7177)
* Add injectable client for CrewAI platform tools

Define an integrations client contract for action discovery and
execution. Keep the existing platform API as the default client to
preserve current behavior.

Allow callers to provide a custom client through CrewaiPlatformTools.

* Fix platform action tool failure tests

* Remove redundant protocol placeholders
2026-08-31 16:01:54 -07:00
Vidit Ostwal
bf56bb13bd ci: require an open issue for first-time contributor PRs (#7169)
* ci: require an open issue for first-time contributor PRs

Gate anyone who is not a returning contributor, and allow the PR only when a closing keyword points at an open issue in this repo.

* ci: accept any open issue mention for first-timer PRs

Drop the closing-keyword regex so #123, owner/repo#N, or an issue URL is enough when that issue is open.

* ci: ignore foreign owner/repo#N in first-timer issue gate

Bare #123 no longer matches the suffix of other/repo#123, so an open local issue cannot keep that PR open.
2026-08-31 22:48:14 +05:30
Vidit Ostwal
614efcdd30 ci: close first-time contributor PRs that lack a linked issue (#7164)
* ci: close first-time contributor PRs that lack a linked issue

* ci: indent FTC close comment so the workflow YAML parses
2026-08-31 21:29:59 +05:30
Vidit Ostwal
0e7625813b fix: bump nltk to 3.10.3 for PYSEC-2026-3726 (#7162)
Force the xml extra and workspace override onto the patched release so pip-audit stops failing on the 3.10.0 symlink file-read advisory.
2026-08-31 21:26:41 +05:30
Ran Shemtov
a35fbc864d docs: update channels guide to current copilotkit channels api (#7016)
* docs: update channels guide to current copilotkit channels api

* docs: translate channels and frontend overview guides to ar, ko, pt-BR

---------

Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-08-31 15:47:58 +00:00
chenshiyang
265697b6f8 docs: refresh retired Gemini model ids (#7003)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-08-31 10:43:31 -03:00
Vinicius Brasil
da4daadba0 Parse platform application selectors (#7148)
Stores application, action, and connection values in an internal
selector. Validates the selector with clearer error messages. Keeps
existing application syntax and legacy API requests unchanged.
2026-08-28 15:36:29 -07:00
Lucas Gomide
e9d4c57b2a fix: run model call hooks on every path and propagate a deny (#7111)
* fix: let a hook deny reach the caller as a deny

A hook that raised `HookAborted` on `pre_model_call` never reached the code
making the call: the LLM layer caught it and returned `False`, which providers
translated into `ValueError("LLM call blocked by before_llm_call hook")`,
dropping the reason and the source and making a policy decision
indistinguishable from a provider outage. Every internal model call then
absorbed that error through the `except Exception` that keeps a provider hiccup
from failing a run, so memory analysis fell back to defaults and the converter
and reasoning handler retried the call that was just denied. The abort now
propagates out of the LLM layer while the boolean convention keeps its
documented `ValueError` via `LegacyHookBlocked`, and the fail-open handlers
around internal model calls re-raise it instead of degrading.

* fix: dispatch model call hooks on the paths that skipped them

A model call was only checked when the executor loop drove it: the
`from_agent is not None` short-circuit in `base_llm` silenced the hooks
for agent planning and step observation, no provider `acall` dispatched
them at all, and `InternalInstructor` bypassed `llm.call` entirely. This
replaces that short-circuit with an explicit
`model_call_hooks_already_dispatched` window so the enclosing caller
claims the dispatch, adds the pre-call dispatch to every provider's
`acall`, and runs the hooks around the Instructor client call. A denial
now emits a denied event instead of being logged and reported as a
provider failure.

* fix: report a boolean-convention deny as a deny, not an outage

A `before_llm_call` hook that blocks by returning `False` reached the five
native providers as a plain `ValueError`, which fell through to their generic
`except Exception` and was logged and emitted as `OpenAI API call failed: ...`
— the same deny raised as `HookAborted` was already labelled correctly, so the
two dialects disagreed on whether a policy decision was a provider outage. The
LLM layer now converts it into `LLMCallBlockedError`, still a `ValueError` so
the fail-open handlers around internal model calls keep absorbing it, but its
own type so a provider can report the decision it is. Since a block is raised
rather than returned, the thirteen callers that turned the return flag into a
raise by hand drop that line, and `_prepare_llm_call` raises the same type.

* fix: keep a denied plan from letting the agent run unplanned

`AgentExecutor.generate_plan` wraps `handle_agent_reasoning()` in a bare
`except Exception`, so guarding the reasoning handler alone still left the
deny absorbed one frame up: the executor logged "Error during planning" and
the agent proceeded with no plan. It now re-raises `HookAborted` like the
other planning boundaries, and the accompanying test also covers the
boolean convention still degrading at a fail-open site.

* fix: stop a denied knowledge query from running the task without knowledge

`handle_knowledge_retrieval` and its async twin wrap the query rewrite in
their own `except Exception`, so guarding `_get_knowledge_search_query`
alone still let `execute_task` continue on the unaugmented prompt after a
deny. Both now emit the terminal `KnowledgeSearchQueryFailedEvent` and
re-raise `HookAborted`, matching the second-frame guard already added to
`AgentExecutor.generate_plan`. Also documents the abort contract on
`PlannerObserver.observe`.

* fix: stop nine callers from re-swallowing a model call deny

CodeRabbit caught the replan path re-swallowing a deny, so an AST sweep of
every caller of a guarded function found the same defeat in nine places:
classic and replan planning, memory recall and memory save on both `Agent`
and `LiteAgent`, the base executor's save, and `LLMGuardrail.__call__`,
which turned a refused call into validation feedback. Each now re-raises
`HookAborted` after emitting whatever terminal event it owes, while every
other failure keeps degrading as before — the knowledge guards move to that
same idiom instead of duplicating their emit.

* fix: pair a denied guardrail with the event it started

Re-raising from `LLMGuardrail` left `process_guardrail` between its started
and completed events, so a denied validation read as one still in flight
rather than a policy decision. It now emits `LLMGuardrailCompletedEvent`
with the deny reason before the abort leaves, matching what every other
guarded site in this change already does.

* fix: stop retrying a task after a hook denied its model call

`Agent.execute_task` funnels every exception into `_handle_execution_error`,
which re-runs the whole task up to `max_retry_limit` times, so a policy deny
read as a transient blip: a crew whose first model call was denied retried and
returned a normal answer. `HookAborted` now joins `_passthrough_exceptions`,
the tuple already reserved for deliberate stops. The new boundary tests drive
the public entry points instead of the frame that makes the call, and count
model calls so a deny that gets retried fails the assertion — ten of the twelve
fail against `main`.

* fix: stop a denied plan step from being reported as a failed step

Making model call hooks reachable on agent-bearing calls put a deny inside
`StepExecutor.execute`, whose broad `except Exception` turned it into
`StepResult(success=False)` and let the plan carry on; `HookAborted` now
joins `ToolExecutionFailedError` in the passthrough handlers there, and
`execute_todos_parallel` re-raises a deny that `return_exceptions=True`
would otherwise record as one failed todo. `_emit_call_denied_event` also
renders the source through the now-public `source_name`, so a hook that
names itself with a callable reads as its name instead of a repr.

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-08-28 12:32:09 -04:00
João Moura
cba6c03646 feat(events): record how a crew run ended, for every user (#7118)
* feat(events): record how a crew run ended, for every user

Crew was the one level with no ungated terminal record. `Crew Execution` and
`end_crew` are both behind `share_crew`, which defaults False, so for
essentially every run there is no end-of-crew span at all - not one with fields
missing. Task outcomes ship ungated, flow outcomes ship ungated; crew being the
exception looks like an accident of history rather than a decision.

Adds `Crew Completed` carrying `outcome` and an explicit `duration_ms`, keyed by
crew_key/crew_id so it joins the existing ungated `Crew Created`. Modelled
directly on `flow_completed_span`, including its reasoning: a separate span
rather than holding `Crew Execution` open, because that span is emitted and
closed at start, so holding it would drop every run that is killed or crashes.

`on_crew_failed` called no telemetry at all before this, so a failed crew
produced nothing. It deliberately does not call `end_crew`, which writes onto
the gated execution span a failed run may never have opened.

Deliberately NOT included, each for a reason:

- Tokens. `crew.token_usage` sums per-agent LLM counters, and two agents sharing
  one LLM object share one counter, so the total double-counts today. Putting it
  on a span would propagate a known-wrong number into a metric. The dedup keys on
  `id(llm._token_usage)`, not `id(llm)` - `Agent.copy()` shallow-copies the LLM -
  and it changes the value of public `Crew.calculate_usage_metrics`, so it earns
  its own change.
- Models. Already on the ungated `Crew Created` span at 99.86% coverage; this
  joins to them by crew_id rather than duplicating.
- Tool counts. The ungated `Tool Usage` span covers only the ReAct path, the
  plan/step path double-emits, and nested crews share one RuntimeState - the
  count needs a design decision on cache hits before it is worth emitting.
- error_type. Needs 4.1's exception-class field factored out of task_events so
  both events share it, rather than duplicated hours after that merged.

Tests use the exporter pattern this suite already uses rather than mocking
`EventListener._telemetry`: EventListener is a singleton, so swapping that leaks
a MagicMock into every later test. The listener tests assert on the stamp
lifecycle instead. Verified order-independent over five randomized runs.

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

* test(telemetry): docstring the crew-completed helpers

Eight of the thirteen functions in this file carried a docstring and five
did not, which is an inconsistency inside the file this PR adds rather
than anything inherited. Documents the two fixtures, the span lookup, the
event-bus runner and the two test methods that were missing one.

No behaviour change: 9 passed, and re-run under random ordering on two
seeds to confirm order-independence.

Deliberately not addressed: the reviewer's 52.17% docstring-coverage
figure is dominated by event_listener.py, where 84 functions - nearly
every pre-existing on_* handler - carry no docstring. That is the file's
convention, and documenting them here would be an unrelated refactor.
The public API this PR adds, Telemetry.crew_completed_span, is documented.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:06:51 -03:00
João Moura
1f6e327b3c feat(events): report machine size as a coarse band, not a core count (#7117)
* feat(events): report machine size as a coarse band, not a core count

`runtime_context` says where a process runs but carries no capacity axis, and
its largest bucket is a catch-all: a gunicorn worker on a VM and
`python main.py > out.log` on a MacBook both report `non_interactive`. Docker
Desktop on a laptop reports `container` via /.dockerenv, and a Remote-SSH shell
on a server reports `vscode_terminal`. So "server or laptop" is not answerable
from it today.

Adds `cpu_band` to the common span attributes, so it rides every span the way
`runtime_context` does rather than sitting on `Crew Created` alone - which would
answer nothing for Flow-only, CLI-only or standalone-agent runs.

Six bands, powers of two, top one open-ended: 1-2, 3-4, 5-8, 9-16, 17-32, 33+.
Open-ended because the exact count is the fingerprint - the observed fleet
maximum is 512, and a span reporting 512 identifies one machine. The vocabulary
is closed and asserted, like KNOWN_CODING_AGENTS and KNOWN_RUNTIME_CONTEXTS.

The share_crew-gated exact `cpus` attribute and the four platform* attributes
are untouched. That gating was a deliberate 2024 classification of machine
fingerprint as shareable content (44e38b1d5), and this does not reverse it: a
band is a range, the gated attribute remains the precise value.

Documents a trap in the docstring rather than leaving it to be rediscovered:
os.cpu_count() reports HOST cores, not the cgroup quota, so a 1-vCPU pod on a
96-core node lands in 33+. Right for "what kind of machine", wrong for "what did
this run get" - os.process_cpu_count() gives the latter but needs 3.13, above
this package's floor.

Docs updated in en/ar/ko/pt-BR, in the default-on Execution Environment row,
stating explicitly that the band is a range and the exact count stays opt-in.

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

* docs(telemetry): say where the cpu band comes from

The Execution Environment row ended "Detection reads only whether known
environment variables are set, never their values". True for the assistant
and runtime-context fields, and wrong for the band this PR adds:
detect_cpu_band() reads os.cpu_count() (runtime_env.py:306) and touches no
environment variable. In a privacy disclosure table that is the kind of
inaccuracy worth a line.

Names the source explicitly and scopes the env-var sentence to the two
fields it actually describes. All four locales at parity.

pt-BR also takes the reviewer's wording fix: "um de uma lista fixa" ->
"um valor de uma lista fixa", and the missing comma before o `project_id`.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:50:10 -07:00
128 changed files with 6540 additions and 1157 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
## فهم تعليمات النظام الافتراضية

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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. يجب على المستخدمين التأكد من عدم تضمين معلومات شخصية في حقول النص. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
## 기본 시스템 지침 이해하기

View File

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

View File

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

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

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

View File

@@ -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...로 시작해야 합니다.
)

View File

@@ -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"), # 조율을 위한 프리미엄
# ... 나머지 설정
)

View File

@@ -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 프롬프트 파일 식별자가 포함됩니다. 사용자들은 텍스트 필드에 개인 정보가 포함되지 않도록 해야 합니다. |

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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