Files
crewAI/lib/devtools/tests/test_agents_md_eval.py
T
João MouraandClaude Opus 5.5 87c2868123 feat(cli): rewrite the scaffolded AGENTS.md and fail PRs that teach coding agents less (#8298)
* feat(cli): rewrite the scaffolded AGENTS.md around the CrewAI + AMP workflow

The template opened with an API reference and mentioned traces, eval and
deployment as optional extras, so coding agents rarely used them. It now
opens with the steps an agent follows in a session (set up, reuse what the
organization has on AMP, run, trace and evaluate every change, deploy when the
work has to run elsewhere), then a short reference of the facts models get
wrong. About 3.3K tokens instead of 11.9K, with outdated claims corrected.

Tests assert the behaviours the old wording carried, check every documented
`crewai` command against the CLI, and keep numbered lists gapless with and
without the evaluation blocks.

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

* feat(cli): index the features AGENTS.md no longer details, and guard the file

The workflow rewrite dropped references to guardrails, knowledge, MCP,
conversational and declarative flows, checkpoints and more. An "Also
available" index under Reference names each one with its API and docs path.

Tests now guard the shipped file on every PR: a reference manifest, a
token budget, text that is wrong on main, imports that must resolve,
python blocks that must parse, and docs paths that must exist.

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

* feat(cli): add a core API section to AGENTS.md for models that don't know CrewAI

Measured with OpenAI models, the workflow rewrite lost the basics they don't
know without the file: flow decorators and state, the @CrewBase class, task
fields, CrewOutput and custom tools. A short Core API section restores them
and folds in the two crew and router facts it would otherwise repeat.

The reference manifest now guards those core references too.

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

* ci: run the AGENTS.md guards on PRs that touch the template

The guards live in lib/cli/tests, which the main test workflow doesn't run.
A path-filtered job runs them when the template, its tests or the docs
pages it links change.

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

* ci: run the AGENTS.md workflow only on pull requests against main

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

* feat(cli): close the gaps where AGENTS.md taught less than the file it replaces

Question by question against today's file, V6 trailed on facts it had cut or
worded loosely: the context window and date options, task output folders and
human review, the guardrail's return shape, test targets before writes, the
eval's structural fixes, logged-in trace visibility, partial selector misses,
written IDs for silent failures, ZIP deploys with an origin, the flow memory
root, and LiteLLM for providers outside the native set. Each is back, verified
on main. Measured on Claude Code (Haiku, Sonnet) and Codex, no question now
scores below today's file. The token budget rises to 4,800 to hold them.

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

* feat(cli): teach the hooks and planning that telemetry counts, and three facts the old file had

The index named kickoff hooks and Crew(planning=True), but the features most
projects use are interception hooks (@on(InterceptionPoint...) in crewai.hooks)
and agent planning (Agent(planning_config=PlanningConfig(...))). Both lines now
lead with those. Three facts today's file taught come back: OpenAI's Responses
API, agents judge while code computes, and answering a paused deployed flow
through AMP's human_feedback_requests API.

Measured on 221 questions in Claude Code (Haiku, Sonnet) and Codex, no question
scores below today's file. The budget rises to 5,000 tokens, guards cover the
new lines, and the never-teach list allows naming reasoning=True as replaced.

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

* feat(devtools): agents-md-eval fails a PR whose AGENTS.md teaches coding agents less

`devtools agents-md-eval` asks a 218-question bank in fresh, tool-less Claude
Code and Codex sessions, once with the base branch's AGENTS.md and once with
this branch's, grades each answer by its key, and exits non-zero when either
agent scores more than 10% below the base. The AGENTS.md workflow runs it on
PRs that change the template, the eval or its questions.

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

* feat(devtools): run the AGENTS.md eval per model and count sessions with no answer

The action asks Claude Code with the latest Haiku and Sonnet and Codex with
gpt-5.6-luna and gpt-5.6-sol, one job per model. A session that hangs, fails or
returns nothing is retried once and then counted; past 2% unanswered the
comparison is called unreliable instead of scored.

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

* ci: run the AGENTS.md eval only on pull requests against main

Drops the manual trigger and the conditions it needed; a test pins the trigger.

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

* feat(devtools): say why AGENTS.md eval sessions gave no answer

Each unanswered session records its exit code and last error line (keys
redacted) or a timeout, and the command prints the most common reasons, so a
run called unreliable can be diagnosed from the CI log.

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

* fix(devtools): grade 'is a different command' as a warning, accept 'call the agent directly'

Two answer-key misses found reading V6's answers: a correct answer that warns
off `crewai update` tripped its trap, and "No. Call the agent directly" didn't
count as needing no crew.

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

* fix(devtools): B-065's trap fires only on advice to run crewai update

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

* fix(devtools): read a failed session's reason from stdout when stderr is empty

Claude Code prints some failures, such as 'Not logged in', on stdout.

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

* fix(devtools): accept 'never a fix' in C-018's key

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

* fix(devtools): read typographic apostrophes and 'replaces' as warnings in traps

Codex writes "don’t"; the negation check knew only "don't", so its warnings
counted as recommendations. "X replaces reasoning=True" now reads as a
warning too.

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

* feat(devtools): add hooks and agent planning questions; D-018 needs the tools, not the package name

Three questions for features telemetry counts and the bank missed: tool-call
hooks, LLM-call hooks and agent planning (221 questions).

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

* feat(cli): AGENTS.md teaches that crewai eval runs the crew itself, logged in or out

Since #8270, `crewai eval` runs the crew, traced, when the project changed
since its last traced run. The file still said to `crewai run` first, and
that a run without a terminal is traced only when logged in, so logged-out
coding agents logged in or stopped instead of evaluating. It now says
`crewai eval` works logged in or out, with or without a terminal, with no
`crewai run` between rounds and no wait on a login.

The CLI test's command parser stops at a group that runs without a
subcommand (`crewai eval`, `crewai eval --models ...`), which `crewai eval`
became when it gained `export`.

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

* fix(devtools): ask whether crewai eval needs a login, and read "doesn't" as a warning

Two questions join the bank: can a logged-out coding agent with no terminal
get a verdict, and does it need `crewai run` first. Three keys follow main:
C-004 and B-051 accept `crewai eval` (it runs and traces the crew), C-032
accepts "flow code". Negations now include doesn't, didn't, aren't and
can't, so "evaluation doesn't require an account" no longer trips the trap
it warns against.

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

* fix(ci): run the AGENTS.md eval from the base commit, with the PR's files as data

The eval job checked out the pull request and ran its devtools with both
API keys in the environment, so a branch could print or send them. It now
checks out the base commit, fetches the pull request's commit only to read
its AGENTS.md and CLAUDE.md (`agents-md-eval --head <sha>`), and runs in the
`agents-md-eval` environment, where the keys can sit behind a required
reviewer. When the base branch has no eval yet, the job skips with a notice.

The eval also names a session's real error: Claude Code can end its output
with a JSON log line after the message ("Invalid API key"), which hid every
Haiku failure. Item text and reasons print as text, so `[tool.crewai]` isn't
dropped and `[/...]` can't raise MarkupError after the paid runs.

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

* fix(cli): AGENTS.md says deploys build from any git origin, not only GitHub

`crewai deploy create` sends whatever `origin` URL the project has, and AMP
builds from GitHub or another git host (`any_git`). The file said an origin
meant GitHub, which pointed GitLab and other users at the wrong fix. The
GitHub integration note now applies to GitHub only, and a guard keeps it so.

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

* fix(ci): run the AGENTS.md eval on pull_request_target, so its keys reach only reviewed code

Under pull_request a branch's own copy of the workflow runs, so a pull
request could edit the eval's steps and still get the keys. The eval now
has its own workflow on pull_request_target: GitHub runs it from the base
branch, the job checks out the base commit, and the pull request's
AGENTS.md and CLAUDE.md are fetched and read as data, never run. It runs
for this repository's branches only, since pull_request_target would hand
the keys to forks too, and it fetches the pull request's commit only once
it knows it will run, so a skip can't fail on the fetch.

The guards stay on pull_request, with no secrets, and no longer persist
the checkout's token for the pull request's code to read.

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

* fix(devtools): accept "go-ahead" as asking first in C-052's key

"Publishing writes to your organization, so it needs your go-ahead first"
asks before publishing, but the key only matched ask, approve, confirm or
permission, so correct answers scored wrong.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:47 +05:30

426 lines
15 KiB
Python

from pathlib import Path
import re
import subprocess
from click.testing import CliRunner
from crewai_devtools import agents_md_eval as ev
import pytest
ITEM: ev.Item = {
"id": "X-001",
"item": "Update the CLI with uv tool install crewai --upgrade.",
"q": "How do I update the CLI?",
"key": [r"uv tool install crewai", r"--upgrade"],
"partial": [r"uv tool"],
"trap": [r"crewai update"],
}
def test_bank_items_are_well_formed():
items = ev.load_items()
ids = [item["id"] for item in items]
assert len(items) >= 200
assert len(ids) == len(set(ids))
for item in items:
assert item["q"].strip() and item["item"].strip() and item["key"], item["id"]
for pattern in item["key"] + item.get("partial", []) + item.get("trap", []):
re.compile(pattern)
@pytest.mark.parametrize(
("answer", "expected"),
[
("Run `uv tool install crewai --upgrade`.", "right"),
("Use uv tool to reinstall it.", "partly"),
("Reinstall it with pip.", "wrong"),
("Run `crewai update`, then `uv tool install crewai --upgrade`.", "wrong"),
("Don't run `crewai update`; use `uv tool install crewai --upgrade`.", "right"),
(
"`crewai update` won't do it: run `uv tool install crewai --upgrade`.",
"right",
),
(
"`crewai update` is a different command; run `uv tool install crewai --upgrade`.",
"right",
),
(
"`crewai update` does something different. Use `uv tool install crewai --upgrade`.",
"right",
),
(
"Don\u2019t run `crewai update`; use `uv tool install crewai --upgrade`.",
"right",
),
(
"`uv tool install crewai --upgrade` replaces `crewai update` for this.",
"right",
),
(
"It doesn't need `crewai update`: run `uv tool install crewai --upgrade`.",
"right",
),
(
"It doesn\u2019t need `crewai update`: run `uv tool install crewai --upgrade`.",
"right",
),
(
"You can't use `crewai update`; run `uv tool install crewai --upgrade`.",
"right",
),
],
)
def test_grade(answer, expected):
assert ev.grade(ITEM, answer) == expected
def test_shipped_text_matches_what_crewai_create_writes():
from crewai_cli.utils import _assistant_instructions
template = ev.read_at(None, f"{ev.TEMPLATES}/AGENTS.md")
assert ev.shipped_text(template) == _assistant_instructions(
template, evaluation=True
)
assert ev.EVALUATION_START not in ev.shipped_text(template)
@pytest.mark.parametrize(
("base", "head", "fails"),
[
(200.0, 180.0, False),
(200.0, 179.9, True),
(200.0, 210.0, False),
(0.0, 0.0, False),
],
)
def test_dropped_allows_up_to_the_max_drop(base, head, fails):
assert ev.dropped(base, head, 0.10) is fails
def test_codex_answer_keeps_only_agent_messages():
events = "\n".join(
[
"Reading additional input from stdin...",
'{"type": "thread.started", "thread_id": "t"}',
'{"type": "item.completed", "item": {"type": "command_execution", "command": "ls"}}',
'{"type": "item.completed", "item": {"type": "agent_message", "text": "Use and_."}}',
'{"type": "item.completed", "item": {"type": "agent_message", "text": "Done."}}',
]
)
assert ev.codex_answer(events) == "Use and_.\nDone."
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) # noqa: S603, S607 - fixed git command in a temp repo
@pytest.fixture
def repo(tmp_path, monkeypatch):
templates = tmp_path / ev.TEMPLATES
templates.mkdir(parents=True)
(templates / "AGENTS.md").write_text(
f"base\n{ev.EVALUATION_START}\neval\n{ev.EVALUATION_END}\n"
)
(templates / "CLAUDE.md").write_text("@AGENTS.md\n")
_git(tmp_path, "init", "-q", "-b", "main")
_git(tmp_path, "add", "-A")
_git(
tmp_path,
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"base",
)
(templates / "AGENTS.md").write_text("head\n")
monkeypatch.chdir(tmp_path)
return tmp_path
def test_write_project_takes_the_file_at_the_ref_or_the_working_tree(repo, tmp_path):
ev.write_project(tmp_path / "base", "main")
ev.write_project(tmp_path / "head", None)
assert (tmp_path / "base" / "AGENTS.md").read_text() == "base\neval\n"
assert (tmp_path / "head" / "AGENTS.md").read_text() == "head\n"
assert (tmp_path / "head" / "CLAUDE.md").read_text() == "@AGENTS.md\n"
def test_write_project_rejects_an_unknown_ref(repo, tmp_path):
with pytest.raises(subprocess.CalledProcessError):
ev.write_project(tmp_path / "base", "no-such-ref")
def _run(monkeypatch, answers: dict[str, str], *args: str):
"""Run the command with Claude Code replaced by canned answers keyed by project dir."""
items = [ITEM] + [{**ITEM, "id": f"X-{n:03d}"} for n in range(2, 101)]
monkeypatch.setattr(ev, "load_items", lambda: items)
monkeypatch.setattr(ev, "write_project", lambda dest, ref: dest.mkdir(parents=True))
def fake(project, question):
if not answers[project.name]:
raise ev.NoAnswer("empty answer")
return answers[project.name]
monkeypatch.setattr(ev, "claude_asker", lambda model: fake)
result = CliRunner().invoke(
ev.agents_md_eval,
["--model", "claude:claude-haiku-5-5", "--workers", "4", *args],
)
# Rich wraps long lines at the runner's width; compare whitespace-normalized text.
return result.exit_code, " ".join(result.output.split())
def test_command_fails_when_the_branch_teaches_less(monkeypatch):
code, output = _run(
monkeypatch,
{"base": "uv tool install crewai --upgrade", "head": "pip install -U crewai"},
)
assert code == 1
assert (
"claude:claude-haiku-5-5: scored -100.0% against origin/main, past the 10% limit."
in output
)
assert "claude:claude-haiku-5-5 lost X-001" in output
def test_command_passes_when_the_branch_holds_or_improves(monkeypatch):
code, output = _run(
monkeypatch, {"base": "use uv tool", "head": "uv tool install crewai --upgrade"}
)
assert code == 0, output
assert "+100.0%" in output
def test_command_respects_a_custom_max_drop(monkeypatch):
code, output = _run(
monkeypatch,
{"base": "uv tool install crewai --upgrade", "head": "use uv tool"},
"--max-drop",
"0.6",
)
assert code == 0, output
assert "-50.0%" in output
def test_command_fails_when_sessions_give_no_answer(monkeypatch):
code, output = _run(
monkeypatch, {"base": "uv tool install crewai --upgrade", "head": ""}
)
assert code == 1
assert "too many sessions gave no answer (0 / 100 of 100)" in output
assert "gave no answer with the branch file: empty answer (100)" in output
def test_command_prints_brackets_in_items_and_reasons_as_text(monkeypatch):
items = [
{**ITEM, "id": f"X-{n:03d}", "item": "Set `[tool.crewai]` in pyproject.toml."}
for n in range(1, 101)
]
monkeypatch.setattr(ev, "load_items", lambda: items)
monkeypatch.setattr(ev, "write_project", lambda dest, ref: dest.mkdir(parents=True))
def fake(project, question):
if project.name == "head":
raise ev.NoAnswer("exit 1: [Errno 8] nodename nor servname provided")
return "uv tool install crewai --upgrade"
monkeypatch.setattr(ev, "claude_asker", lambda model: fake)
result = CliRunner().invoke(
ev.agents_md_eval, ["--model", "claude:claude-haiku-5-5"]
)
output = " ".join(result.output.split())
assert result.exit_code == 1
assert "[Errno 8] nodename nor servname provided (100)" in output
assert "lost X-001: Set `[tool.crewai]` in pyproject.toml." in output
def test_command_reads_the_head_file_at_the_ref_it_is_given(monkeypatch):
refs: dict[str, str | None] = {}
def write_project(dest, ref):
refs[dest.name] = ref
dest.mkdir(parents=True)
monkeypatch.setattr(ev, "write_project", write_project)
monkeypatch.setattr(ev, "load_items", lambda: [ITEM])
monkeypatch.setattr(
ev, "claude_asker", lambda model: lambda project, question: "uv tool"
)
CliRunner().invoke(
ev.agents_md_eval,
["--model", "claude:claude-haiku-5-5", "--base", "abc123", "--head", "def456"],
)
assert refs == {"base": "abc123", "head": "def456"}
def test_command_without_codex_needs_no_openai_key(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
code, output = _run(monkeypatch, {"base": "x", "head": "x"})
assert code == 0, output
@pytest.mark.parametrize(
"spec", ["claude", "codex:", "gemini:gemini-3", ":gpt-5.6-sol"]
)
def test_command_rejects_a_malformed_model(spec):
result = CliRunner().invoke(ev.agents_md_eval, ["--model", spec])
assert result.exit_code == 2
assert "use claude:<model> or codex:<model>" in result.output
def test_default_models_are_the_four_the_action_runs():
assert [ev.parse_model(spec) for spec in ev.MODELS] == [
("claude", "claude-haiku-5-5"),
("claude", "claude-sonnet-5-5"),
("codex", "gpt-5.6-luna"),
("codex", "gpt-5.6-sol"),
]
def test_answer_retries_once_then_reports_why_there_was_none():
replies = iter([subprocess.TimeoutExpired("claude", 300), "Use and_."])
def flaky(project, question):
reply = next(replies)
if isinstance(reply, Exception):
raise reply
return reply
def silent(project, question):
raise ev.NoAnswer("exit 1: overloaded")
assert ev.answer(flaky, Path("."), "How?") == ("Use and_.", "")
assert ev.answer(silent, Path("."), "How?") == (None, "exit 1: overloaded")
def test_unanswered_sessions_score_zero_and_are_counted_by_reason(tmp_path):
def half(project, question):
if question == "second":
raise ev.NoAnswer("empty answer")
return "uv tool install crewai --upgrade"
items = [{**ITEM, "id": "A", "q": "first"}, {**ITEM, "id": "B", "q": "second"}]
totals, missing = ev.score(half, tmp_path, items, reps=2, workers=2)
assert totals == {"A": 1.0, "B": 0.0}
assert missing == {"empty answer": 2}
def test_codex_runs_in_its_own_existing_home_without_api_keys(tmp_path, monkeypatch):
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs, Path(kwargs["env"]["CODEX_HOME"]).is_dir()))
message = '{"type": "item.completed", "item": {"type": "agent_message", "text": "Use and_."}}'
return subprocess.CompletedProcess(argv, 0, stdout=message, stderr="")
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
monkeypatch.setattr(ev.subprocess, "run", fake_run)
ask = ev.codex_asker(tmp_path / "codex-home", "gpt-5.6-sol")
assert ask(tmp_path, "How?") == "Use and_."
(login, login_kwargs, home_existed), (exec_argv, exec_kwargs, _) = calls
assert login[:2] == ["codex", "login"] and home_existed
assert login_kwargs["input"] == "sk-test"
assert exec_argv[exec_argv.index("-m") + 1] == "gpt-5.6-sol"
assert not {"OPENAI_API_KEY", "ANTHROPIC_API_KEY"} & set(exec_kwargs["env"])
@pytest.mark.parametrize(
("returncode", "stdout", "stderr", "reason"),
[
(
1,
"partial",
"Reading input...\nAPI Error: 529 overloaded\n",
"exit 1: API Error: 529 overloaded",
),
(2, "", "", "exit 2: no error output"),
(
1,
"Not logged in · Please run /login\n",
"",
"exit 1: Not logged in · Please run /login",
),
(0, " ", "", "empty answer"),
(1, "", "Invalid API key sk-ant-abc123XYZ\n", "exit 1: Invalid API key sk-***"),
(
1,
"Invalid API key · Fix external API key\n",
'{"model":"claude-haiku-5-5","query_source":"sdk"}\n',
"exit 1: Invalid API key · Fix external API key",
),
],
)
def test_a_session_without_an_answer_says_why(
tmp_path, monkeypatch, returncode, stdout, stderr, reason
):
monkeypatch.setattr(
ev.subprocess,
"run",
lambda argv, **kwargs: subprocess.CompletedProcess(
argv, returncode, stdout=stdout, stderr=stderr
),
)
with pytest.raises(ev.NoAnswer, match=re.escape(reason)):
ev.claude_asker("claude-haiku-5-5")(tmp_path, "How?")
EVAL_WORKFLOW = ".github/workflows/agents-md-eval.yml"
def _eval_workflow():
import yaml
return yaml.safe_load(ev.read_at(None, EVAL_WORKFLOW))
def test_the_workflow_runs_the_default_models():
job = _eval_workflow()["jobs"]["eval"]
assert job["strategy"]["matrix"]["model"] == list(ev.MODELS)
def test_the_eval_runs_on_pull_requests_against_main_from_this_repository_only():
workflow = _eval_workflow()
triggers = workflow[True] # PyYAML reads the key `on` as the boolean true
assert list(triggers) == ["pull_request_target"]
assert triggers["pull_request_target"]["branches"] == ["main"]
assert triggers["pull_request_target"]["paths"] == [
"lib/cli/src/crewai_cli/templates/AGENTS.md",
"lib/cli/src/crewai_cli/templates/CLAUDE.md",
]
assert (
workflow["jobs"]["eval"]["if"]
== "github.event.pull_request.head.repo.full_name == github.repository"
)
def test_the_eval_runs_only_the_base_branchs_code_with_the_keys():
"""The keys reach only code from the base commit; the PR's commit is fetched as data."""
job = _eval_workflow()["jobs"]["eval"]
steps = job["steps"]
checkout = next(
step for step in steps if "actions/checkout" in step.get("uses", "")
)
assert checkout["with"]["ref"] == "${{ github.event.pull_request.base.sha }}"
assert checkout["with"]["persist-credentials"] is False
run = next(
step for step in steps if "devtools agents-md-eval" in step.get("run", "")
)
assert '--base "$BASE_SHA" --head "$HEAD_SHA"' in run["run"]
assert run["env"]["HEAD_SHA"] == "${{ github.event.pull_request.head.sha }}"
assert job["environment"]["name"] == "agents-md-eval"
def test_the_eval_fetches_the_pull_request_only_when_it_runs():
steps = _eval_workflow()["jobs"]["eval"]["steps"]
names = [step["name"] for step in steps]
fetch = next(step for step in steps if "git fetch" in step.get("run", ""))
assert names.index(fetch["name"]) > names.index("Check the eval and the API keys")
assert fetch["if"] == "steps.keys.outputs.run == 'true'"