mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-20 18:13:49 +00:00
* test(flow): unskip the conversational end-to-end suite The `conversational_graph_broken` marker parked 21 end-to-end conversational tests with the reason "the definition-first start migration intentionally stopped scanning inherited methods, so that graph no longer registers". That is no longer true: `_iter_flow_methods` walks the MRO for `__conversational_only__` methods (dsl/_utils.py:406-420), so a `conversational = True` subclass does register `route_conversation`, `converse_turn`, `end_conversation` and `answer_from_history_turn` — which `test_flow_definition.py:391-407` already asserts. Removing the marker takes the file from 47 passed / 21 skipped to 68 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(flow): make conversational opt-in unmistakable Opting a Flow into chat took two statements, and forgetting one failed silently. With `@ConversationConfig(...)` but no `conversational = True`: `FlowDefinition.conversational` came back `None`, the built-in graph never registered, and `handle_turn()` returned `None` without appending a message or raising — while `chat()` reported "only available on conversational flows" on a class that was literally decorated with a conversational config. Three changes: - `ConversationConfig.__call__` now also sets `conversational = True`. Every field on the config is consumed only by the conversational graph, so a decorated non-conversational Flow could only ever discard it. - `FlowConversationalDefinition.enabled` defaults to True. The block is absent on non-conversational flows, so declaring it is the opt-in; `enabled: false` remains an explicit opt-out. - `handle_turn()` raises like `chat()` and `stream_turn()` already do instead of silently returning `None`. Setting `conversational = True` by hand still works and is still the way to opt in without a config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(flow): let a declaration drive conversational mode `FlowDefinition.conversational` was written by the DSL projection and read by nothing: every conversational gate resolved through `type(self).flow_definition()` — the class projection — instead of `self._definition`, the declaration a flow was actually built from. So `Flow.from_declaration()` on a definition with a conversational block produced a flow that reported itself non-conversational, dropped the user message, and never registered its declared routes. Resolution rules, applied consistently: - Structure (enabled, methods, route labels, builtin/internal routes) comes from `self._definition`, which is the loaded declaration for a declarative flow and the class projection otherwise. Both paths now agree. - Behavior (`conversational_config`) still prefers the class attribute, which can hold live objects — a configured LLM, a custom BaseLLM, a response_format model class — that the serializable definition degrades to a config dict or a `module:qualname` ref. Reading the definition first would silently downgrade every decorated Python flow. A declaration-built flow has no class config, so `_config_from_definition` supplies one, cached for stable identity. - A declared `state:` block is never replaced. `_create_default_extension_state` is consulted before `_create_definition_state`, so returning `ConversationState` there discarded every field the declaration asked for. It now yields to a declared state and only supplies the default when nothing else does. The class-scoped `_is_conversational` / `_conversational_definition` classmethods are gone; the existing instance-scoped `_is_conversational_enabled` is the single gate. A router `response_format` that survived serialization as a ref or schema dict is dropped with a warning rather than handed to `llm.call()`, which needs a real class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(flow): synthesize the built-in conversational methods for declarations A declaration carrying `conversational: {}` loaded clean and then ran zero methods and returned `None`, because the four built-in graph handlers are inherited from `_ConversationalMixin` and a declaration has nothing to inherit from. Authors had to name `crewai.experimental.conversational_mixin:_Conversational Mixin.route_conversation` and three siblings by hand. `Flow._extend_definition` is a new runtime extension hook, called once `_definition` is resolved and before methods are bound. The conversational mixin overrides it to fill in `route_conversation`, `converse_turn`, `end_conversation` and `answer_from_history_turn` when they are missing, using the same code refs the DSL projection already emits so a declaration and a class projection of the same flow produce identical method definitions. Synthesis is deliberately a runtime concern, not a contract one: `FlowDefinition` stays independent of the authoring layer and of the engine, as `test_flow_definition_contract_is_dsl_agnostic` requires, and a loaded declaration still serializes back to exactly what its author wrote. Route descriptions are now carried by the contract. The DSL projects a handler docstring's first line into `FlowMethodDefinition.description`, and the router catalog reads that before falling back to the live docstring. This also fixes a real defect: for a declarative flow `getattr(type(self), handler_name, None)` is `None`, and the old code read `None.__doc__` — so the router LLM was told a route's description was "The type of the None singleton." Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(flow): let an agent or crew handler reply in a conversation (#7034) `handle_turn` promotes a handler's return value to the assistant message when the handler did not append one itself, but the check required `isinstance(result, str)`. Declarative `agent` and `crew` actions return `LiteAgentOutput` and `CrewOutput`, whose text lives on `.raw` — so the most natural declarative handler was exactly the one whose reply never reached the transcript. `_is_public_turn_result` now unwraps `.raw` before deciding, matching `_stringify_result`, which already did. The routing-artefact guards are applied to the unwrapped text, so an output echoing a route label or this turn's intent is still not promoted. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(flow): keep privately recorded agent results out of the transcript Unwrapping `.raw` in `_is_public_turn_result` made the end-of-turn fallback promote `LiteAgentOutput` / `CrewOutput` objects that the handler had already recorded via `append_agent_result` with the default private visibility. That call does not set `_assistant_reply_appended`, so the fallback republished the very object the handler asked to keep private — defeating `visible_agent_outputs`. Reproduced against `main` for contrast: no leak before the unwrap, leak after. `append_agent_result` now remembers the object it recorded for the duration of the turn, and the fallback skips anything already routed that way. The check is identity-based on purpose: a handler that records scratch work privately and then returns a user-facing summary still gets that summary promoted, which a simple "handler already handled it" flag would have broken. Found by Cursor Bugbot on #7033. * feat(flow): mark a declarative chat flow conversational on the instance A declaration enables chat through `conversational.enabled`, without the `conversational = True` class attribute. Callers outside this package capability-check that attribute -- the AG-UI serving guide states it as a requirement -- so it disagreed with `_is_conversational_enabled()` and a declarative conversational flow looked non-conversational from outside. `_extend_definition` now sets it on the instance when the definition enables chat. Instance-only on purpose: the DSL projection reads the attribute off the *class* to decide whether to emit a conversational block, so setting it there would make every later subclass look conversational. Verified on a real declarative flow: `conversational` and `stream_turn` both now satisfy the documented capability check, while `Flow.conversational` and any later subclass stay False. * refactor(flow): derive routing-artefact labels from the effective routes `_is_public_turn_result` matched a literal set of route labels, duplicating knowledge that `_effective_builtin_routes()` already owns. A declaration that adds a builtin route was not covered, so a handler echoing that label could be promoted into the transcript -- the same class of divergence already fixed for `route_turn`. Verified the derived set is byte-identical to the old literal one for a class-based flow, so this is a pure generalization: `conversation` and `route_to_flow` stay explicit because neither is a route. Also replaces a tuple-index lambda in the chat REPL test with a named `input_fn`; it relied on tuple evaluation order and on the list being mutated before its length was read. Both found by CodeRabbit on #7033. * docs(flow): document declarative conversational flows The authoring skill told LLM authors "use top-level `conversational` only when the user asks for a chat flow" while documenting none of its 19 fields — there was no ModelSpec for either conversational model, so the API reference appendix skipped them entirely. - Adds both conversational models to the skill reference, with field descriptions, and registers them under the existing `conversational` skip so `skills(skips=["conversational"])` still suppresses the whole block. - Adds authoring rules: do not declare the built-in graph, do not name a handler after the route it listens to, do not declare state unless it needs extra fields, and give every route handler a description. - Documents the declarative form in the conversational-flows guide across en, ar, ko and pt-BR, including what is supplied automatically, how to run it, and what a declaration cannot express (live LLM objects, a response_format class, route_turn overrides). - `crewai run` on a conversational declaration now says it has no chat loop and points at handle_turn/chat, instead of quietly running a single turn and exiting. It fails closed: a flow that cannot be inspected runs normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(flow): render the conversational router section in the skill Both conversational models shared one `Conversational` section, and the template renders only the first model of a non-union section. The router's fields were therefore dropped from the API reference and the generated link to them pointed at a heading that did not exist. Also softens the built-in-handler rule: `_extend_definition` keeps an author-supplied entry and the guide documents that override, so the skill should say to omit those handlers by default rather than never declare them. Adds regression tests for both sections rendering, for every field of both models appearing, and for `skips=["conversational"]` suppressing both. Both found by CodeRabbit on #7035. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(flow): correct the route-description rule in the authoring skill The rule said every route handler must define `description`, but `conversational.router.route_descriptions` is the higher-precedence source -- `_build_route_catalog` checks the overrides before falling back to the method description. Either one describes a route; the rule now says so, and says what happens when a route has neither. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: ViditOstwal <viditostwal@gmail.com> Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
535 lines
18 KiB
Python
535 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import subprocess
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import click
|
|
from crewai_core.project import ProjectDefinitionError, configured_project_definition
|
|
from pydantic import ValidationError
|
|
|
|
from crewai_cli.input_prompt import (
|
|
closest_name,
|
|
is_interactive,
|
|
parse_inputs_json,
|
|
prompt_for_inputs,
|
|
)
|
|
from crewai_cli.utils import build_env_with_all_tool_credentials
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from crewai.flow.flow import Flow
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def run_declarative_flow_in_project_env(
|
|
definition: str | Path, inputs: str | None = None
|
|
) -> None:
|
|
"""Run a declarative flow inside the project's Python environment."""
|
|
if is_declarative_flow_project_env() or not _has_project_file():
|
|
run_declarative_flow(definition=definition, inputs=inputs)
|
|
return
|
|
|
|
# Re-run inside the project env (so the flow loads with the project's deps).
|
|
# The configured definition is re-resolved there; forward --inputs so the
|
|
# in-env run kicks off with the same values instead of losing them.
|
|
command = ["uv", "run", "crewai", "run"]
|
|
if inputs is not None:
|
|
command += ["--inputs", inputs]
|
|
_execute_declarative_flow_command(command)
|
|
|
|
|
|
def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
|
|
"""Plot a declarative flow inside the project's Python environment."""
|
|
if is_declarative_flow_project_env() or not _has_project_file():
|
|
plot_declarative_flow(definition=definition)
|
|
return
|
|
|
|
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
|
|
|
|
|
|
def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
|
|
"""Run a declarative flow from a definition path.
|
|
|
|
Inputs come from one place: the flow's own state schema. Any ``--inputs``
|
|
JSON is layered on top as an override, missing required fields are prompted
|
|
for interactively, and everything is validated against the schema before
|
|
kickoff — so a bare ``crewai run`` on a configured flow just works.
|
|
"""
|
|
# Load the project's .env before kickoff, mirroring the JSON-crew path
|
|
# (run_crew._run_json_crew) so flow projects pick up API keys/config the
|
|
# same way regardless of where crewai is installed.
|
|
from dotenv import load_dotenv
|
|
|
|
env_file = Path.cwd() / ".env"
|
|
if env_file.exists():
|
|
load_dotenv(env_file, override=True)
|
|
|
|
provided = parse_inputs_json(inputs) or {}
|
|
|
|
flow = load_declarative_flow(definition)
|
|
|
|
if _flow_is_conversational(flow):
|
|
click.secho(
|
|
" This flow declares `conversational`, and `crewai run` has no chat "
|
|
"loop yet — it would run a single turn and exit.\n"
|
|
" Drive it from Python for now: `flow.chat()` for a terminal REPL, "
|
|
"or `flow.handle_turn(message, session_id=...)` per message.",
|
|
fg="yellow",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
resolved_inputs = _resolve_flow_inputs(flow, provided)
|
|
|
|
# The TUI is the interactive default. Headless contexts run directly on the
|
|
# terminal: deploy/CREWAI_DMN, piped output, CI — anything without an
|
|
# interactive TTY. is_interactive() already folds in the CREWAI_DMN check.
|
|
# Human-feedback flows also run on the terminal: their methods collect input
|
|
# via the flow runtime's blocking input()/Rich prompts (and async feedback
|
|
# returns a pending marker rather than completing), neither of which the
|
|
# Textual TUI can handle correctly.
|
|
if is_interactive() and not _flow_uses_human_feedback(flow):
|
|
_run_declarative_flow_tui(flow, resolved_inputs or None)
|
|
return
|
|
|
|
try:
|
|
result = flow.kickoff(inputs=resolved_inputs or None)
|
|
except Exception as exc:
|
|
click.echo(
|
|
f"An error occurred while running the declarative flow: {exc}",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1) from exc
|
|
click.echo(_format_result(result))
|
|
|
|
|
|
def _run_declarative_flow_tui(
|
|
flow: Flow[Any], resolved_inputs: dict[str, Any] | None
|
|
) -> Any:
|
|
"""Run a declarative flow on the CrewAI TUI (the interactive default).
|
|
|
|
Mirrors the declarative-crew TUI contract (``run_crew._run_json_crew``):
|
|
a failed flow exits non-zero, a user quit ends the process so in-flight LLM
|
|
work stops, and choosing Deploy chains into the deploy command.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
from crewai.events.event_listener import EventListener
|
|
|
|
from crewai_cli.crew_run_tui import CrewRunApp
|
|
|
|
# The flow runtime (unlike a Crew constructor) doesn't create the event
|
|
# listener, and the TUI's trace/telemetry features depend on it.
|
|
EventListener()
|
|
|
|
# The STEPS panel and header are driven by flow method events. A flow may
|
|
# declare ``config.suppress_flow_events`` (a headless/production
|
|
# optimization) which would leave STEPS stuck on "waiting…" here — so force
|
|
# emission on for the interactive TUI run. The headless path never reaches
|
|
# this and keeps the flow's declared setting.
|
|
try:
|
|
flow.suppress_flow_events = False
|
|
except Exception:
|
|
logger.debug(
|
|
"Could not disable suppress_flow_events for the flow TUI", exc_info=True
|
|
)
|
|
|
|
app = CrewRunApp(crew_name=flow.name or type(flow).__name__)
|
|
app._flow = flow
|
|
app._flow_inputs = resolved_inputs
|
|
app._flow_method_types = _flow_method_types(flow)
|
|
|
|
app.run()
|
|
|
|
_print_flow_post_tui_summary(app)
|
|
|
|
if app._status == "failed":
|
|
raise SystemExit(1)
|
|
|
|
if app._status not in ("completed", "failed"):
|
|
# User quit mid-run. kickoff runs in a thread worker that cannot be
|
|
# force-cancelled, so end the process to stop in-flight LLM and tool
|
|
# work instead of letting it burn tokens in the background.
|
|
click.secho("\n Run cancelled.", fg="yellow")
|
|
sys.stdout.flush()
|
|
os._exit(130)
|
|
|
|
if getattr(app, "_want_deploy", False):
|
|
from crewai_cli.run_crew import _chain_deploy
|
|
|
|
_chain_deploy()
|
|
|
|
return app._crew_result
|
|
|
|
|
|
def _flow_is_conversational(flow: Flow[Any]) -> bool:
|
|
"""True if the declaration turns on conversational mode.
|
|
|
|
Fails closed: a flow we cannot inspect runs the normal single-kickoff path
|
|
rather than being blocked from running at all.
|
|
"""
|
|
try:
|
|
conversational = flow._definition.conversational
|
|
except AttributeError:
|
|
logger.debug("Could not inspect flow for conversational mode", exc_info=True)
|
|
return False
|
|
return conversational is not None and conversational.enabled
|
|
|
|
|
|
def _flow_uses_human_feedback(flow: Flow[Any]) -> bool:
|
|
"""True if any declarative method declares ``@human_feedback``.
|
|
|
|
Such flows need the flow runtime's interactive stdin / Rich prompts, which
|
|
don't compose with Textual — so they run on the terminal, not the TUI.
|
|
"""
|
|
try:
|
|
return any(
|
|
method.human_feedback is not None
|
|
for method in flow._definition.methods.values()
|
|
)
|
|
except Exception:
|
|
logger.debug("Could not inspect flow for human feedback", exc_info=True)
|
|
return False
|
|
|
|
|
|
def _flow_method_types(flow: Flow[Any]) -> dict[str, str]:
|
|
"""Map each declarative method name to its ``call`` type (crew/agent/…).
|
|
|
|
Best-effort: the STEPS panel shows this as a dim label. Method events don't
|
|
carry the call type, so it's read from the flow definition up front.
|
|
"""
|
|
method_types: dict[str, str] = {}
|
|
try:
|
|
for name, method_definition in flow._definition.methods.items():
|
|
method_types[name] = method_definition.do.call
|
|
except Exception:
|
|
logger.debug("Could not derive flow method types", exc_info=True)
|
|
return method_types
|
|
|
|
|
|
def _print_flow_post_tui_summary(app: Any) -> None:
|
|
"""Print a compact result panel after the flow TUI exits."""
|
|
import time
|
|
|
|
from rich.console import Console
|
|
from rich.markdown import Markdown
|
|
from rich.padding import Padding
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
console = Console()
|
|
elapsed = (app._elapsed_frozen or (time.time() - app._start_time)) or 0.0
|
|
|
|
out_tokens = app._output_tokens + app._live_out_tokens
|
|
token_parts = []
|
|
if app._input_tokens:
|
|
token_parts.append(f"↑{app._input_tokens:,}")
|
|
if out_tokens:
|
|
token_parts.append(f"↓{out_tokens:,}")
|
|
token_str = " ".join(token_parts)
|
|
if token_str:
|
|
token_str += " tokens"
|
|
|
|
crewai_red = "#FF5A50"
|
|
crewai_teal = "#1F7982"
|
|
|
|
if app._status == "completed":
|
|
summary = Text()
|
|
summary.append(" ✔ Flow complete", style=f"bold {crewai_teal}")
|
|
summary.append(f" in {elapsed:.1f}s", style="dim")
|
|
if token_str:
|
|
summary.append(f" {token_str}", style="dim")
|
|
console.print(
|
|
Panel(
|
|
summary,
|
|
title=f" {app._crew_name} ",
|
|
title_align="left",
|
|
border_style=crewai_teal,
|
|
padding=(0, 1),
|
|
)
|
|
)
|
|
if app._final_output:
|
|
console.print()
|
|
console.print(Text(" Final Result", style=f"bold {crewai_teal}"))
|
|
console.print()
|
|
console.print(Padding(Markdown(app._final_output), (0, 2)))
|
|
elif app._status == "failed":
|
|
content = Text()
|
|
content.append(" ✘ Failed", style=f"bold {crewai_red}")
|
|
content.append(f" after {elapsed:.1f}s\n", style="dim")
|
|
if app._error:
|
|
content.append(f"\n {app._error}\n", style=crewai_red)
|
|
console.print(
|
|
Panel(
|
|
content,
|
|
title=f" {app._crew_name} ",
|
|
title_align="left",
|
|
border_style=crewai_red,
|
|
padding=(0, 1),
|
|
)
|
|
)
|
|
|
|
|
|
def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]:
|
|
"""Resolve kickoff inputs from the flow's state schema.
|
|
|
|
Warns on unknown keys, prompts for missing required fields (unless
|
|
non-interactive), and validates types before kickoff. Exits with a pointed
|
|
message when a required input is still missing or an input is invalid.
|
|
"""
|
|
schema = _flow_state_schema(flow)
|
|
if schema is None:
|
|
# dict / unschematized state — nothing to derive; pass inputs through.
|
|
return dict(provided)
|
|
|
|
properties = {
|
|
name: spec
|
|
for name, spec in (schema.get("properties") or {}).items()
|
|
if name != "id"
|
|
}
|
|
state_model = type(flow.state)
|
|
defaults = _flow_state_defaults(flow)
|
|
|
|
# ``id`` signals a persistence restore: kickoff hydrates the full state from
|
|
# storage, so required fields may come from the restored state rather than
|
|
# --inputs. We still filter the rest of the payload below, but skip the
|
|
# required-field prompt and pre-kickoff validation, which would otherwise
|
|
# fail on fields the resume will supply.
|
|
restoring = "id" in provided
|
|
|
|
# Unknown keys are almost always typos — warn and drop them (they'd fail
|
|
# structured-state validation at kickoff anyway). ``id`` is a reserved
|
|
# kickoff key rather than a state field, so forward it untouched.
|
|
collected: dict[str, Any] = {}
|
|
for key, value in provided.items():
|
|
if key == "id":
|
|
collected["id"] = value
|
|
continue
|
|
if key in properties:
|
|
collected[key] = value
|
|
continue
|
|
suggestion = closest_name(key, properties)
|
|
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
|
click.secho(
|
|
f" Ignoring unknown input '{key}' — not in the flow's state schema.{hint}",
|
|
fg="yellow",
|
|
err=True,
|
|
)
|
|
|
|
if restoring:
|
|
return collected
|
|
|
|
missing = _missing_required(state_model, {**defaults, **collected})
|
|
if missing and _is_interactive():
|
|
collected.update(
|
|
prompt_for_inputs(
|
|
missing,
|
|
title="Flow inputs",
|
|
subtitle="This flow needs the following to run.",
|
|
describe=lambda name: (properties.get(name) or {}).get("description"),
|
|
coerce=lambda name, raw: _coerce_input(raw, properties.get(name) or {}),
|
|
)
|
|
)
|
|
missing = _missing_required(state_model, {**defaults, **collected})
|
|
|
|
if missing:
|
|
for name in missing:
|
|
description = (properties.get(name) or {}).get("description")
|
|
suffix = f" — {description}" if description else ""
|
|
click.secho(
|
|
f" Missing required input '{name}'{suffix}", fg="red", err=True
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
_validate_flow_inputs(state_model, {**defaults, **collected})
|
|
return collected
|
|
|
|
|
|
def _is_interactive() -> bool:
|
|
"""Prompt only in an interactive terminal, never in non-interactive mode."""
|
|
return is_interactive()
|
|
|
|
|
|
def _flow_state_schema(flow: Any) -> dict[str, Any] | None:
|
|
"""Return the flow's state JSON schema, or ``None`` for dict/plain state."""
|
|
state = getattr(flow, "state", None)
|
|
if state is None or isinstance(state, dict):
|
|
return None
|
|
model_json_schema = getattr(type(state), "model_json_schema", None)
|
|
if not callable(model_json_schema):
|
|
return None
|
|
try:
|
|
schema = model_json_schema()
|
|
except Exception:
|
|
return None
|
|
return schema if isinstance(schema, dict) else None
|
|
|
|
|
|
def _flow_state_defaults(flow: Any) -> dict[str, Any]:
|
|
"""Declared state defaults (``state.default``) from the flow definition."""
|
|
state_definition = getattr(getattr(flow, "_definition", None), "state", None)
|
|
default = getattr(state_definition, "default", None)
|
|
return dict(default) if isinstance(default, dict) else {}
|
|
|
|
|
|
def _missing_required(state_model: Any, values: dict[str, Any]) -> list[str]:
|
|
"""Required state fields not satisfied by ``values`` (defaults + inputs)."""
|
|
try:
|
|
state_model.model_validate(values)
|
|
except ValidationError as exc:
|
|
return [
|
|
str(error["loc"][0])
|
|
for error in exc.errors()
|
|
if error.get("type") == "missing" and error.get("loc")
|
|
]
|
|
return []
|
|
|
|
|
|
def _validate_flow_inputs(state_model: Any, values: dict[str, Any]) -> None:
|
|
"""Validate inputs against the state schema; exit with pointed type errors."""
|
|
try:
|
|
state_model.model_validate(values)
|
|
except ValidationError as exc:
|
|
for error in exc.errors():
|
|
location = ".".join(str(part) for part in error.get("loc", ()))
|
|
click.secho(
|
|
f" Invalid input '{location}': {error.get('msg')}", fg="red", err=True
|
|
)
|
|
raise SystemExit(1) from exc
|
|
|
|
|
|
def _coerce_input(raw: str, spec: dict[str, Any]) -> Any:
|
|
"""Best-effort coerce a prompted string to the field's JSON-schema type."""
|
|
field_type = spec.get("type")
|
|
if field_type == "integer":
|
|
try:
|
|
return int(raw)
|
|
except ValueError:
|
|
return raw
|
|
if field_type == "number":
|
|
try:
|
|
return float(raw)
|
|
except ValueError:
|
|
return raw
|
|
if field_type == "boolean":
|
|
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
return raw
|
|
|
|
|
|
def plot_declarative_flow(definition: str | Path) -> None:
|
|
"""Plot a declarative flow from a definition path."""
|
|
try:
|
|
flow = load_declarative_flow(definition)
|
|
flow.plot()
|
|
except Exception as exc:
|
|
click.echo(
|
|
f"An error occurred while plotting the declarative flow: {exc}", err=True
|
|
)
|
|
raise SystemExit(1) from exc
|
|
|
|
|
|
def load_declarative_flow(definition: str | Path) -> Any:
|
|
"""Load a declarative Flow instance from a definition path."""
|
|
try:
|
|
from crewai.flow.flow import Flow
|
|
except ImportError as exc:
|
|
click.echo(
|
|
"Running declarative flows requires the full crewai package.",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1) from exc
|
|
|
|
definition_path = Path(definition).expanduser()
|
|
try:
|
|
if not definition_path.is_file():
|
|
if definition_path.exists():
|
|
click.echo(
|
|
f"Invalid --definition path: {definition} is not a file.",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1)
|
|
click.echo(
|
|
f"Invalid --definition path: {definition} does not exist.", err=True
|
|
)
|
|
raise SystemExit(1)
|
|
except OSError as exc:
|
|
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
|
|
raise SystemExit(1) from exc
|
|
|
|
try:
|
|
return Flow.from_declaration(path=definition_path)
|
|
except (OSError, UnicodeError, ValueError, ValidationError) as exc:
|
|
click.echo(
|
|
f"Unable to read --definition path {definition_path}: {exc}",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1) from exc
|
|
|
|
|
|
def configured_project_declarative_flow(
|
|
pyproject_data: dict[str, Any] | None = None,
|
|
project_root: Path | None = None,
|
|
) -> Path | None:
|
|
"""Return the configured declarative flow source for flow projects."""
|
|
root = project_root or Path.cwd()
|
|
if pyproject_data is None and not (root / "pyproject.toml").is_file():
|
|
return None
|
|
|
|
try:
|
|
return configured_project_definition(
|
|
"flow",
|
|
pyproject_data=pyproject_data,
|
|
project_root=root,
|
|
)
|
|
except ProjectDefinitionError as exc:
|
|
raise click.UsageError(str(exc)) from exc
|
|
|
|
|
|
def _execute_declarative_flow_command(command: list[str]) -> None:
|
|
env = build_env_with_all_tool_credentials()
|
|
|
|
try:
|
|
subprocess.run( # noqa: S603
|
|
command,
|
|
capture_output=False,
|
|
text=True,
|
|
check=True,
|
|
env=env,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
raise SystemExit(e.returncode) from e
|
|
except Exception as e:
|
|
click.echo(
|
|
f"An unexpected error occurred while running the declarative flow: {e}",
|
|
err=True,
|
|
)
|
|
raise SystemExit(1) from e
|
|
|
|
|
|
def is_declarative_flow_project_env() -> bool:
|
|
import os
|
|
|
|
return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None
|
|
|
|
|
|
def _has_project_file(project_root: Path | None = None) -> bool:
|
|
root = project_root or Path.cwd()
|
|
return (root / "pyproject.toml").is_file()
|
|
|
|
|
|
def _format_result(result: Any) -> str:
|
|
raw_result = getattr(result, "raw", result)
|
|
if isinstance(raw_result, str):
|
|
return raw_result
|
|
|
|
try:
|
|
return json.dumps(raw_result, default=str)
|
|
except TypeError:
|
|
return str(raw_result)
|