* fix(skills): resolve registry skills through the installed AMP client
Skill downloads built their own `PlusAPI` and authenticated it from
`CREWAI_USER_PAT`, the platform integration token, or the saved CLI login.
Managed runtimes have no user credential to offer: they install a client of
their own, which `load_agent_from_repository` already resolves through, so
Agent Repository lookups worked while the skill downloads beside them failed
with 401.
Skills now resolve their client the same way, via `resolve_plus_client()` next
to the hook it reads. A client that can't fetch skills falls back to
environment credentials and warns, so older runtimes behave as they do today.
`resolve_plus_response()` shares the sync/async bridging both lookups need,
since `PlusAPI` is synchronous while managed clients are not.
Version pinning, which the same bug was hiding:
- Registry refs accept `@org/name@version`, and `@org/name@v1.2.0` since people
write it both ways. `parse_skill_ref()` returns a `SkillRef(org, name,
version)`; `parse_registry_ref()` keeps its `(org, name)` shape and drops the
pin, so existing callers are unaffected
- Agent Repository agents record a version per skill, which was parsed off the
response and dropped. Those pins now travel with the refs, so publishing a
new version of a skill no longer changes every agent that uses it
- A pinned ref only accepts a project-local copy declaring that version in its
`metadata.version` frontmatter, and the cache reports a miss when the version
it recorded differs — so a pin re-resolves rather than loading another
version. Unpinned refs keep hitting the cache as before
- An unknown pin fails instead of quietly falling back to the newest version
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(skills): reject a blank version pin instead of floating to latest
A blank `version` passed to `download_skill` read as "unpinned" and quietly
resolved the latest version, which is not what a caller supplying one asked
for — and it disagreed with `parse_skill_ref`, which already rejects empty
pins. Not reachable through `resolve_registry_ref` or the Agent Repository
auto-pinning, both of which only ever pass a non-empty version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(skills): carry the caller's context into the worker thread
When resolve_plus_response bridges an async client from inside a running loop
it runs the coroutine on a worker thread, which starts with empty ContextVars.
A client reading runtime state there — the platform integration token, flow
context — would see defaults rather than the caller's values, which is hard to
diagnose from the resulting auth or routing failure.
Copy the context across, matching how the parallel-summarization bridge in this
module already does it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ordinary agent with a tool fails on the whole GPT-5.6 family:
Agent(role=..., goal=..., backstory=...,
llm=LLM(model="openai/gpt-5.6-sol"), tools=[multiply])
Function tools with reasoning_effort are not supported for gpt-5.6-sol in
/v1/chat/completions. To use function tools, use /v1/responses or set
reasoning_effort to 'none'.
Nothing sets reasoning_effort -- not the user, not CrewAI. The family applies a
server-side default and then refuses it once tools are present. Confirmed with
raw HTTP, no CrewAI involved, on a payload with no reasoning_effort key at all:
gpt-5.6-sol tools, no reasoning_effort key -> 400
gpt-5.6-sol tools, reasoning_effort="none" -> OK
gpt-5.5 tools, no reasoning_effort key -> OK
gpt-5.4 tools, no reasoning_effort key -> OK
gpt-5.2 tools, no reasoning_effort key -> OK
So this is GPT-5.6 only, and it needs an explicit "none" -- dropping the key is
what the rejected request already looked like.
Recovered from the error rather than a model list: catch the 400, resend with
reasoning_effort="none", once. No model names, so a family OpenAI restricts later
works without a release here. Detection matches the structured `param` field plus
the message, so the unrelated "Unsupported value" 400 that o1/o3 return for
"none" isn't mistaken for this one, and the retry can't loop.
Verified against main with real agents (no reasoning_effort anywhere):
model no tools tools tools + reasoning=True
gpt-5.6-sol ok / ok 400 / ok hang / ok
gpt-5.6-terra ok / ok 400 / ok - / ok
gpt-5.6-luna ok / ok 400 / ok - / ok
gpt-5.5 ok ok -
gpt-5.2 ok ok ok / ok
gpt-4o ok ok -
On main, tools + Agent(reasoning=True) produced no output and no error and was
killed at 420s; gpt-5.2 with the same config finishes in ~40s. Both the 400 and
that hang are fixed.
Tests: 15 cases, including agent definitions with tools, with tools plus
reasoning=True, and without tools. tests/llms + tests/agents -> 961 passed
(1 pre-existing unrelated failure from a local OLLAMA_API_KEY env leak).
Ruff + mypy clean.
* fix(openai): make tool calling work on the Responses API path
An agent with tools on api="responses" never produced an answer. It returned the
raw tool-call list instead:
[{'id': 'call_...', 'name': 'multiply', 'arguments': '{"a":17,"b":23}'}]
Three defects in the chain, all on the Responses side only:
1. `is_tool_call_list()` knew the OpenAI-nested, Anthropic, Bedrock and Gemini
shapes but not the Responses one ({"id", "name", "arguments"} -- no nested
"function", no "input"). The list wasn't recognized as tool calls, so the
executor handed it back verbatim as the final answer.
2. `extract_tool_call_info()` read "arguments" only from a nested "function"
object, falling back to "input". For the Responses shape both missed and the
arguments silently became {}, so the tool would have run with no input.
3. With those fixed the tool ran, then the follow-up request 400'd:
Invalid type for 'input[1].content': expected one of an array of objects
or string, but got null instead.
Tool calling is expressed differently by the two APIs. Chat Completions uses an
assistant message carrying `tool_calls` with content: None, then role: "tool"
results. The Responses API uses flat function_call / function_call_output items
keyed by call_id. Those messages were passed through untranslated.
`_to_responses_input()` now converts them. Messages without tool calls pass
through unchanged, so nothing else moves.
Verified end to end against the live API:
api="responses" + tools -> 391 (was raw tool-call JSON)
chained multi-step tool calls -> 400 (17*23, then +9)
completions path (control) -> 391 (unchanged)
The generated `input` payload was also posted to /v1/responses directly and
accepted, and the pre-fix chat-shaped payload confirmed as a 400.
This is why api="responses" never worked for agents: the provider side has had a
full Responses implementation since #4258/c4c9208, but the executor never learned
the shape it emits. Fixing it also unblocks routing gpt-5.4+ tool calls to the
Responses API instead of dropping reasoning_effort.
Tests: 12 cases covering recognition, extraction (including that the Chat
Completions and Bedrock shapes are unaffected), translation of assistant/tool
messages, parallel calls, assistant text alongside tool calls, non-string tool
output, and the full prepared `input` list.
* fix(openai): prefer Responses "call_id" over the item's own "id"
Per CodeRabbit review. A raw Responses function_call item carries both keys with
different values, confirmed against the live API:
keys ['arguments', 'call_id', 'id', 'name', 'status', 'type']
id fc_0adeb715c5d740c7006a65ccb72b948199872ad8b5a5c53108
call_id call_dEoHFrYnOgWYvk17FymdcDZ5
function_call_output must reference call_id. Reading the item's own "id" would
produce a tool result the model can't correlate back to its invocation.
Our own _extract_function_calls_from_response already maps item.call_id into "id",
so the normal path was correct and the existing tests passed. But
extract_tool_call_info is a shared helper reached from every provider's tool loop,
and a raw Responses item is a plausible thing to hand it -- silently picking the
wrong identifier is a bad trap to leave in place for one line of guard.
Tests: raw item extraction asserting call_id is chosen over id, and a round-trip
check that the id extracted from a call is the one sent back with its result.
997 passed across tests/llms, test_agent_utils and tests/agents (1 pre-existing
unrelated failure from a local OLLAMA_API_KEY env leak). Real two-agent chained-tool
run still returns the correct answer.
---------
Co-authored-by: João Moura <joaomdmoura@gmail.com>
The pro tier is not served by /v1/chat/completions. Probing the live endpoints:
model /v1/chat/completions /v1/responses
gpt-5-pro 404 OK
gpt-5.5-pro 404 OK
gpt-5.4-pro 404 OK
gpt-5.2-pro 404 OK
o1-pro 404 OK
o3-pro 404 OK
Since api defaults to "completions", LLM(model="openai/gpt-5-pro") fails with
"Model ... not found", which is misleading -- the model exists, the endpoint is
wrong. OpenAI's own 404 text ("This is not a chat model") doesn't make the fix
obvious either.
These requests now route to the Responses API automatically, which is verified to
work for every model above. An explicit api= setting is always honoured.
Model matching normalizes the configured string first, so "openai/gpt-5-pro" and
"gpt-5-pro-2025-10-06" both resolve to "gpt-5-pro". It's an exact list rather
than a "-pro" substring, so a custom deployment named "gpt-4-pro-custom" isn't
swept up.
The chat-completions 404 handler also gained an actionable message: when the
response says responses-only, or the model is a known pro model, the error names
api="responses" instead of just reporting "not found".
Tests: 29 cases covering name normalization, detection, routing (including that
call() reaches the Responses handler), and both 404 message paths.
bedrock-agentcore 1.7.0 has GHSA-j6g5-3hh3-pgw8 (CVE-2026-16796, high):
argument-delimiter injection in CodeInterpreter.install_packages(). It fails
the pip-audit vulnerability scan on every PR in the repo.
The patch is 1.18.1, which requires boto3>=1.43.31. The old <1.8.0 cap plus
aiobotocore~=3.5.0 (botocore<1.42.92) made that unsatisfiable, so the AWS
stack moves together:
- bedrock-agentcore >=1.7.0,<1.8.0 -> >=1.18.1,<2.0.0
- boto3 ~=1.42.90 -> ~=1.43.46 (aws + bedrock extras)
- aiobotocore ~=3.5.0 -> ~=3.8.0 (aws + bedrock extras)
aiobotocore 3.8.0 allows botocore <1.43.47 and boto3 1.43.46 pins botocore
1.43.46, so the ranges overlap.
Verified: uv lock resolves, pip-audit reports no vulnerabilities (3 existing
ignores, none new), 48 bedrock tests pass, and both bedrock toolkits import
cleanly. BrowserClient.{start,stop,generate_ws_headers} and
CodeInterpreter.{start,stop,invoke} are unchanged in 1.18.1.
* chore: bump json-repair to 0.60.1 and un-ignore fixed vulns in scan
- json-repair 0.25.3 -> 0.60.1 (fixes GHSA-xf7x-x43h-rpqh)
- pyOpenSSL already at 26.2.0 in lock (covers CVE-2026-27448, CVE-2026-27459)
- remove the corresponding --ignore-vuln flags from vulnerability-scan.yml
* fix: adapt _safe_repair_json to json-repair 0.60 semantics
json-repair >= 0.60 returns an empty string for plain-text input and
wraps brace-enclosed junk in a single-element list instead of the old
""/{} sentinel values. Treat both as unrepairable so the original
tool input is preserved.
* chore: fix CI - bump gitpython/pyasn1, drop stale type ignores
- gitpython 3.1.50 -> 3.1.52 (GHSA-2f96-g7mh-g2hx, GHSA-v396-v7q4-x2qj,
GHSA-956x-8gvw-wg5v; fixed in 3.1.51)
- pyasn1 0.6.3 -> 0.6.4 (GHSA-8ppf-4f7h-5ppj, GHSA-hm4w-wwcw-mr6r)
- json-repair 0.60 ships type stubs; remove now-unused
type: ignore[import-untyped] comments flagged by mypy
* fix: dispatch execution_end hook on failed crew and flow executions
The `execution_end` interception point only fired after a successful
kickoff, so consumers never learned about failed runs. Crew kickoff
paths (`kickoff`/`akickoff`) and the flow runtime (`kickoff_async`,
`resume_async`) now dispatch it on the failure path too, with new
additive `status` ("completed"/"failed") and `error` fields on
`ExecutionEndContext`. Pairing flags guarantee exactly-once dispatch,
keep the start/end pairing invariant, and the original exception
propagates unchanged.
* fix: track execution_end pairing per invocation for reentrant flows
Reentrant kickoffs on the same Flow instance are supported (usage
aggregation already accommodates them), but the instance-level pairing
booleans let an inner kickoff's completion mark the outer execution as
ended, skipping the outer failure's `execution_end`. The pairing state
now lives in each `kickoff_async` invocation's locals, and the resume
path passes a per-invocation holder into `_resume_async_body`. Crew
keeps its instance flags since crew kickoffs are not reentrant on the
same instance (`kickoff_for_each` copies the crew).
* fix: handle async get_agent in load_agent_from_repository
The enterprise PlusClient.get_agent() is async, but
load_agent_from_repository() calls it synchronously. When the enterprise
client is hooked in, client.get_agent() returns a coroutine instead of a
response, causing "'coroutine' object has no attribute 'status_code'".
This adds an inspect.isawaitable() check after the call: if the response
is a coroutine, it is properly awaited via asyncio.run() (or via a
thread-pool executor if an event loop is already running).
Co-authored-by: Joe Moura <joao@crewai.com>
* fix: resolve mypy type-checker errors for async awaitable handling
* fix: remove unused type: ignore comment
---------
Co-authored-by: Joe Moura <joao@crewai.com>
Registry downloads initialized PlusAPI without credentials, so uncached skills failed outside CLI-authenticated flows and were blocked entirely in non-interactive environments.
Use CREWAI_USER_PAT first, then the platform integration token, then the saved login token, and pass CREWAI_ORGANIZATION_UUID. Remove the non-interactive cache-only restriction so runtime downloads work.
* feat(skills)!: promote Skills Repository out of experimental
The registry-backed Skills Repository (crewai skill create/publish/
install/list, @org/name refs, global cache) is now mainline:
- CLI: `crewai skill ...` is a top-level group; the CREWAI_EXPERIMENTAL
gate and the now-empty `crewai experimental` group are removed.
- Runtime: registry.py, cache.py, and events.py move from
crewai.experimental.skills into crewai.skills next to the loader;
the require_experimental_skills() gate is gone.
crewai.experimental.skills remains as a deprecated re-export shim.
- Docs: concepts/skills now leads with the CLI workflow and documents
the create -> publish -> install lifecycle.
Linear: n/a (requested promotion)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): org-scoped publish only + docs in all languages
Skills are always scoped to the publishing organization, like tools:
drop the --public/--private flags from `crewai skill publish` and
always send is_public=False to the registry. CLI tests assert the flag
is rejected and the API never receives a public publish.
Translate the new CLI-first Quick Start and the create -> publish ->
install lifecycle section into ar, pt-BR, and ko concepts/skills docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): address review comments on the promotion PR
- Back-compat shim now aliases the old submodules in sys.modules so
`crewai.experimental.skills.registry/cache/events` imports (and patch
targets) resolve to the real crewai.skills modules, not just the
package-root re-exports.
- `crewai skill publish` actually enforces the git-state check that
--force claims to skip: unsynced repos block publishing (mirroring
tool publish); standalone skill dirs outside any git repo publish
without a check.
- Explicit UTF-8 encoding on SKILL.md and cache-metadata reads/writes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): fail closed when git state cannot be validated on publish
Follow deploy's pattern: construct git.Repository(fetch=False) and only
treat "not a Git repository" as skippable — any other git error
(fetch/auth/misconfiguration) now blocks publish with a --force escape
hatch instead of silently bypassing the sync check.
Also single-style imports in the shim test (CodeQL) with the dotted
shim import covered via importlib.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(skills): fetch before sync check on publish; bump mcp past advisories
Publish now refreshes remote-tracking refs (repository.fetch()) before
is_synced(), so ahead/behind is judged against the actual remote rather
than stale local refs; a failing fetch blocks publish with the --force
escape hatch. Adds a fail-closed test for fetch errors.
Raise mcp to >=1.28.1,<2 (locks 1.28.1): the ~=1.26.0 pin blocked
GHSA-hvrp-rf83-w775 / GHSA-jpw9-pfvf-9f58 (fixed 1.27.2) and
GHSA-vj7q-gjh5-988w (fixed 1.28.1), which were failing pip-audit on
this PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Vinicius Brasil <vini@hey.com>
* docs: add Flows in Studio documentation
Documents the new Flows build mode in Studio: why deterministic
workflows with agentic steps matter, the three core node types
(Single Agent, Crews, Router), and Agent Repository publish/pull
sync across organizations.
Includes a rollout banner for the week of July 20th, English source
plus pt-BR, Korean, and Arabic translations, and nav entries for
both edge and v1.15.2 (Crew Studio group renamed to Studio).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: limit Flows in Studio docs to edge version
Versioned snapshots are updated by a separate script, so remove the
v1.15.2 copies and revert its nav changes; the page now lives only
under edge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix: sync kickoff-completed event with OUTPUT hook result
`CrewKickoffCompletedEvent` still carried the pre-hook `TaskOutput`, so
AMP/OTEL consumers never saw `OUTPUT` mutations even though the returned
`CrewOutput` was updated. Sync `final_task_output.raw` from the post-hook
payload before emit, matching `FlowFinishedEvent`.
* style: drop OUTPUT sync comment and rename crew output test
This commit adds the organization ID parameter to the PlusAPI client, in
addition to the settings file. This allows for settings the organization
programmatically.
Repository responses can include null optional fields such as
`reasoning`. Treat them as omitted so Agent defaults apply instead of
failing validation.
* docs: group execution hooks and document all hook contexts
The hooks pages sat flat in the learn nav and only documented the LLM
and tool call contexts, with examples built on the legacy decorators.
Groups them under a collapsible "Execution Hooks" section, adds
`step-hooks` and `execution-boundary-hooks` pages covering every hook
context from source, reworks the LLM and tool pages to lead with `@on`
while keeping the decorators, and links the orphaned
`before-and-after-kickoff-hooks` page into the group.
* docs: prefix hook cross-links with /en so they resolve in edge
The new edge-only hooks pages linked each other with versionless paths
like `/learn/step-hooks`, which mintlify resolves against the frozen
default version where those pages do not exist, breaking the CI link
check. Uses the `/en/learn/...` form the other edge pages already use.
* docs: use /edge prefix for links to edge-only hooks pages
The `/en/learn/...` form still resolves against the default frozen
version, where `step-hooks` and `execution-boundary-hooks` do not exist
yet, so the link checker kept failing. Links now use the explicit
`/edge/en/learn/...` form, matching how `consuming-streams.mdx` linked
to edge-only streaming pages before they were frozen.
* feat: add pre_step and post_step interception points on task execution
Introduces `StepContext` and the two step points in the dispatcher, and
wires them around agent execution in `task.py` (sync and async paths):
`pre_step` fires after `TaskStartedEvent` with the task context as
payload, `post_step` fires before `TaskCompletedEvent` with the
`TaskOutput`, and hook replacements are rebound in both directions.
* feat: wire pre_step and post_step on flow method execution
Dispatches the step points around each flow method with
kind="flow_method": `pre_step` receives the dumped call params and maps
returned edits back onto args/kwargs, `post_step` can rewrite the method
result before it is recorded. Conformance tests cover per-method firing
and output rewriting.
* docs: rework execution hooks page around the @on api
Replaces the standalone interception hooks catalog with a single
`execution-hooks.mdx` page that teaches `@on` as the primary way to
write hooks, covering the full ten-point catalog across task, flow, and
LLM execution. The legacy per-point decorators stay documented in a
closing section, and the `docs.json` navigation drops the removed page.
* feat: wire execution-boundary interception points
Adds the typed interception contexts (`crewai/hooks/contexts.py`) and wires
the `execution_start`, `input`, `output`, and `execution_end` points for both
crews and flows through the dispatcher. `prepare_kickoff` and
`Flow.kickoff_async` fire `execution_start`/`input` so a hook can rewrite
resolved inputs before the run, while `Crew._create_crew_output` and the flow
tail fire `output`/`execution_end` so the final result can be observed or
replaced. Closes the eight critical-path points without touching the legacy
hooks.
* fix: correct execution-boundary hook ordering and input aliasing
Reworks the crew and flow boundary seams flagged in review. `OUTPUT` and
`EXECUTION_END` now run before the completion event (`CrewKickoffCompletedEvent`
and `FlowFinishedEvent`) so a `HookAborted` no longer leaves a spurious
completed signal and a returned payload replacement is honored on the emitted
and returned result. Boundary contexts alias `inputs` to the same object as
`payload` instead of a fresh dict from `or`, so in-place edits survive
read-back. Flows re-publish the resolved inputs into `flow_inputs` baggage
after the `INPUT` hook so trigger-payload injection observes hook rewrites, and
a resumed flow now dispatches `OUTPUT`/`EXECUTION_END` on its completion path.
* chore: drop redundant seam comments from execution-boundary wiring
Removes two inline comments narrating the OUTPUT/EXECUTION_END dispatch
ordering in `crew.py` and the flow runtime, plus a stray sentence about
enterprise adapters in the conformance-suite docstring. Comment-only
cleanup, no behavior change.
* fix: keep crew output typed across boundary hook dispatch
`_create_crew_output` reassigned `crew_output` from the hook contexts'
`payload`, which is typed `Any`, so mypy flagged `no-any-return` at the
function's return. Cast the payload back to `CrewOutput` after each
dispatch and split the `ExecutionEndContext` construction to satisfy
`ruff format`'s line-length limit.
* feat: add generic interception-hook dispatcher
Introduces `crewai/hooks/dispatch.py` as a single engine behind every
interception point: a hook receives a typed context, may mutate or replace
its `payload`, or raise `HookAborted(reason, source)` to stop the operation.
The full `InterceptionPoint` catalog is frozen from day zero, with global and
contextvar-scoped registries, an `@on` decorator, a no-op fast path, and a
`HookDispatchedEvent` for telemetry. The four existing `before/after_llm_call`
and `before/after_tool_call` hooks become adapters over the dispatcher, so the
legacy dialect and `return False` semantics keep working unchanged while
gaining the new contract.
* fix: harden interception dispatcher against review findings
Corrects several dispatcher edge cases surfaced in review. `_default_reducer`
now reports a modification only when a `payload` is actually applied, the
`agents=` filter falls back to `agent_role` for contexts without an `agent`
object, and `unregister` resolves the filter wrapper stashed by `on` so a
filtered hook can be removed. The tool-hook runners honor the executing
agent's `verbose` flag instead of silently swallowing hook errors, and the
ReAct tool path now runs `POST_TOOL_CALL` on blocked calls to match the
native paths. Also adds abort-telemetry coverage and replaces the flaky
absolute no-op timing budget with a relative one.
* fix: honor scoped hooks on direct llm calls and register @on crew methods
Direct agent-less LLM calls short-circuited on the empty global hook list,
so hooks registered only for the current `scoped_hooks()` context never
ran; the direct-call helpers now defer to `dispatch`, which resolves
scoped hooks behind its own no-op fast path. `CrewBase` likewise only
scanned the legacy `is_*_hook` markers, so `@on(InterceptionPoint.X)`
methods were silently dropped — it now registers them on the dispatcher
with filters applied and `self` bound. Also tightens result typing across
the tool-call seams so `mypy` stays green.
* refactor: scope InterceptionPoint to the points this layer wires
The dispatcher only fires the model- and tool-call boundaries, so
`InterceptionPoint` now lists just those four rather than the full future
catalog. New points are introduced alongside the seams that dispatch them,
keeping every layer free of enum members with no live consumer. The
dispatcher unit tests that borrowed unused points as generic examples are
remapped onto the four kept points.
* test: pin per-hook fail-open at the LLM and tool seams
The dispatcher swallows a hook's exception per hook rather than around the
whole loop, so one buggy hook no longer silently skips every hook registered
after it. These seam-level tests pin that behavior through
`_setup_before_llm_call_hooks` and `run_before/after_tool_call_hooks`, and
confirm an intentional `return False` block still short-circuits later hooks.
* fix: run execution-scoped hooks on the agent executor model seams
`_setup_before/after_llm_call_hooks` only ran the executor's snapshot
hook lists, so hooks registered via `scoped_hooks()` never fired on
`PRE/POST_MODEL_CALL` during normal agent execution, while the tool
seams (which go through `dispatch`) merged them. The seams now append
the current scope's hooks after the snapshot via `get_scoped_hooks`,
matching dispatch's global-then-scoped ordering, and a scoped-only
registration no longer short-circuits the seam.
* fix: don't clobber native tool-call responses in after-LLM hooks
Registering any `after_llm_call` hook broke native tool execution: the
executor invokes `_setup_after_llm_call_hooks` on the intermediate
response that carries the model's tool calls, the non-str payload was
stringified for the response-rewrite pass, and the executor then treated
that string as a final answer instead of executing the tools. Structured
payloads (neither `str` nor `BaseModel`) now pass through untouched,
mirroring the isinstance guard `_invoke_after_llm_call_hooks` already
applies on the direct-call path; hooks still fire on the follow-up
textual response.
Fixes#6529
* style: shorten the tool-call guard comment
The vulnerability scan started failing when PYSEC-2026-2132 (click) and
PYSEC-2026-2253..2257 (pillow) were published on Jul 12. Both have fixed
releases within our constraints, so `uv.lock` upgrades click to 8.4.2 and
pillow to 12.3.0. A newer json-repair advisory (GHSA-xf7x-x43h-rpqh) also
surfaced; its fix is outside the `json-repair~=0.25.2` pin and 0.25.x lacks
the vulnerable `schema_repair` module, so it joins the ignore list in
`vulnerability-scan.yml` with a justification.
handle_turn() (and stream_turn) decided "did the handler append its
reply?" by snapshotting the assistant-message count before kickoff and
appending the stringified result when the count came back unchanged. A
handler that appends its reply and then trims state.messages to a cap —
a normal bounded-context pattern — left the count unchanged, so the
fallback appended the reply a second time on every turn once trimming
engaged, and the duplicates then crowded real turns out of the capped
window.
Replace the count heuristic with an explicit per-turn flag:
append_assistant_message() sets _assistant_reply_appended, handle_turn
and stream_turn clear it before kickoff and only fall back when no
assistant message was appended during the turn. The now-unused
_assistant_message_count() helper is removed.
Fixes EPD-181.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(tools)!: make tool-result caching opt-in instead of on by default
Tool-result caching defaulted to on (Crew.cache=True, and standalone
agents self-wired a CacheHandler at construction), so an LLM calling
the same tool with identical arguments twice in one run silently got
the first result back without the tool executing. For live-data tools
that is a confidently stale answer; for state-mutating tools the second
action is silently dropped.
Caching is now opt-in with the machinery unchanged:
- Crew.cache defaults to False; Crew(cache=True) restores today's
behavior exactly (agents still default to participating when a crew
offers its handler, and Agent(cache=False) still opts an agent out).
- Standalone agents no longer self-wire a cache; Agent(cache=True) or
an explicit cache_handler opts in. Previously even Crew(cache=False)
agents cached via this self-wired handler.
- Per-tool cache_function write gating is unchanged once opted in.
Existing tests that exercised the caching machinery now opt in
explicitly; new regression tests cover the default (both identical
calls execute), crew-level opt-in dedup, and agent-level wiring.
Fixes EPD-180.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agent): don't let copy() turn the cache default into an explicit opt-in
Agent.copy() rebuilds from model_dump(), which includes the field
default cache=True, so the copy's model_fields_set contained "cache"
and _setup_agent_executor wired a CacheHandler the source agent never
opted into (Bugbot review finding). Drop "cache" from the dump when it
was not explicitly set on the source; explicit opt-ins still survive
copying.
Also sync the Crew and BaseAgent class docstrings with the new opt-in
cache semantics (CodeRabbit review findings).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agent): preserve cache_handler-only opt-in across Agent.copy()
copy() excludes cache_handler from the rebuilt agent, so an agent that
opted into tool-result caching solely via an explicit cache_handler
lost caching after copy() (Bugbot review finding). Carry the consent as
cache=True on the copy when the source has a handler wired and hasn't
explicitly disabled caching — the copy wires its own fresh handler,
matching pre-change copy semantics (copies never shared the source's
handler instance).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(crew): offer the crew cache handler to the hierarchical manager
The hierarchical manager agent is created in _create_manager_agent,
outside the validation-time agents loop that offers the crew's cache
handler — and managers no longer self-wire a handler — so
Crew(cache=True) hierarchical runs never cached the manager's
delegation tool calls (Bugbot review finding). Offer the shared crew
handler when the crew opted in; a user-provided manager with
cache=False stays excluded via the existing set_cache_handler gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(agent): only construction-time cache opt-ins survive Agent.copy()
The previous copy() fix treated any wired cache_handler as consent, but
agents that merely received the crew's shared handler at kickoff
(set_cache_handler from Crew(cache=True)) never opted in themselves —
their copies must not become standalone cachers (Bugbot review
finding). Record the opt-in signal in _setup_agent_executor, which runs
at construction before any crew wiring can happen, and have copy()
consult that flag instead of inspecting cache_handler after the fact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(tools): stop rewriting the authored tool description at construction
BaseTool.model_post_init silently replaced the public description field
with the LLM-facing composite ("Tool Name: ...\nTool Arguments: ...\n
Tool Description: <authored>"), breaking equality assertions on authored
text and hiding the extra prompt tokens from token-careful authors.
The authored description now survives construction as written. The
composite is composed on demand via a new formatted_description property
on BaseTool and CrewStructuredTool (shared format_description_for_llm
helper), and every prompt path that relied on the baked-in composite —
render_text_description_and_args, ToolUsage._render, and tool-usage
error messages — now renders through it, so the text the LLM sees is
unchanged.
The helper strips any pre-existing composite block before composing, so
tools deserialized from old checkpoints and adapters that still bake the
composite into the field (e.g. the crewai-tools MCP adapter) don't get
double-wrapped. BaseTool._generate_description remains as a no-op hook
because subclasses override it and model_post_init still calls it.
Fixes EPD-179.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tools): harden composite-description handling after review
- Anchor the pre-baked-composite check to the actual three-line block
shape instead of a naive substring match, so authored prose that
merely mentions "Tool Description:" is never truncated (CodeRabbit /
Bugbot review finding). Shared as
strip_composite_description_prefix() and reused by the function-
calling schema builder, which had the same naive split.
- Make render_text_description_and_args tolerate duck-typed tools
without a real formatted_description string (fixes CI: step-executor
tests pass Mock tools whose auto-created attribute is not a str).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
Agent.kickoff() returned LiteAgentOutput with a plain dict at
.usage_metrics and no token_usage attribute, while Crew.kickoff()
returned CrewOutput with a UsageMetrics object at .token_usage and no
usage_metrics attribute — so a usage accessor written for one path
raised AttributeError on the other, and every consumer had to
duck-type both shapes.
Give both result types both surfaces, each name with one consistent
shape everywhere: .token_usage is a UsageMetrics object and
.usage_metrics is a plain dict, on both LiteAgentOutput and CrewOutput.
Added as read-only properties, so existing fields, serialization, and
constructors are unchanged.
Fixes EPD-178.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(agent): report per-call usage metrics on kickoff results
Agent.kickoff() populated result.usage_metrics from the LLM instance's
lifetime token accumulator, so counts grew across calls and pooled
across agents sharing one LLM object — a second agent's first turn
appeared to cost the whole preceding session.
Snapshot the accumulator when a kickoff starts and report the delta on
the result (guardrail retries included), via the new
UsageMetrics.delta_since(). The LLM instance's cumulative counters are
untouched: get_token_usage_summary() keeps lifetime totals for
crew-level aggregation, and its docstring now states that scope
explicitly. Applies to both Agent and the deprecated LiteAgent, sync
and async paths.
Fixes EPD-177.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(agent): drop lite_agent.py diff, add guardrail-retry usage test
Per review: LiteAgent's kickoff path is no longer used, so the per-call
usage snapshot only needs to live in agent/core.py — revert the
lite_agent.py changes entirely. This also removes the duplicated
_current_usage_summary helper and the instance-attr baseline CodeRabbit
flagged.
Add the requested guardrail-retry regression test: a guardrail that
rejects the first attempt and accepts the second must yield
usage_metrics covering both attempts (2x a single-attempt kickoff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
In conversational flows, a falsy return from an overridden route_turn()
fell back to the sticky state.last_intent from a previous turn, silently
re-running the prior turn's handler for an unhandled input.
The fallback exists for the legacy default_intents path, where
receive_user_message() classifies the intent fresh each turn. Track that
per-turn classification in _turn_classified_intent (cleared on every turn
reset) and route on it instead, so a falsy route_turn() now falls through
to the built-in answer_from_history/converse defaults and never reuses
stale routing state.
Fixes EPD-176.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>