Skip to content

feat(eval-harness): agentic retrieval via configurable Zep tools - #575

Merged
jackaldenryan merged 11 commits into
mainfrom
jack/eval-harness-tool-calling
Jul 26, 2026
Merged

feat(eval-harness): agentic retrieval via configurable Zep tools#575
jackaldenryan merged 11 commits into
mainfrom
jack/eval-harness-tool-calling

Conversation

@jackaldenryan

@jackaldenryan jackaldenryan commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Adds a tool-calling mode to the eval harness: the response model is given retrieval tools (defined as ToolSpec entries in config/evaluation_config/tools.py — name, description, JSON Schema, async executor, enabled, requires_doc_graph) and decides what to fetch from Zep before answering, either instead of or alongside the deterministic context block. --tools/--no-tools is the only switch that decides whether tools are used, --context-block/--no-context-block toggles deterministic retrieval, and --max-tool-calls/--max-tool-iterations are enforced independently (parallel calls in one turn cost one iteration) with a forced answer turn once either is spent, while --require-tool-call stops the model answering before it retrieves. Context completeness is graded on the union of every successful tool output so it still measures retrieval rather than generation, and latency now reports overall user-facing latency split into tool wall-clock vs non-tool LLM time, plus per-tool counts, failures, and cap/downgrade counters.

Search knobs are now sent only where Zep honours them — max_characters for scope="auto" (which ignores limit and reranker) and limit/reranker for the other scopes — since the previous behavior would have silently capped tool retrieval at 2500 characters under a different reranker than the context block, invalidating the cross-mode comparison. Judge rubrics move into config/evaluation_config/judge_prompts.py so each run snapshots the rubric that graded it; the completeness rubric gained tool-awareness, so re-run baselines rather than comparing completeness against older runs.

Verified offline against fake Zep/LLM clients using real SDK response types (99 checks: budget enforcement, graded-context assembly, latency attribution, provider-quirk handling, statistics and summary rendering); nothing has been exercised against live Gemini or Zep.

🤖 Generated with Claude Code


Note

Medium Risk
Large change to the core evaluation path (retrieval, judging, metrics, and CLI), with intentional breaking comparability for completeness scores and response_* latency versus pre-change runs; default behavior remains context-block-only (USE_TOOLS = False).

Overview
Adds agentic retrieval to the eval harness: the response model can call Zep retrieval tools (search_memory, search_documents, get_user_profile) instead of—or on top of—the deterministic context block. zep_evaluate.py gains --tools, --context-block, budgets, and --require-tool-call; tool_agent.py runs the multi-turn loop with caps, forced final answer, and tool_trace recording.

Completeness grading now uses the union of successful tool outputs (plus any injected context block), with tool arguments and harness error text excluded so retrieval stays measurable separately from generation. Judge rubrics move to judge_prompts.py (snapshotted per run); shared search rendering lives in formatting.py.

Search behavior is aligned across paths: query truncation, CONTEXT_BLOCK_RERANKER / scope-specific tool bounds (max_characters for auto, limit/reranker otherwise), and startup validation for unsupported rerankers. retry.py stops retrying 400/422 immediately.

Results add retrieval_configuration, richer timing (answer_latency_*, tool vs answer-LLM split), tool aggregates, and per-test tool fields; response_* timing meaning changed and the completeness rubric is tool-aware—baselines should be re-run before comparing to older runs. README and SKILL docs describe modes, fair comparisons, and how to read the new metrics.

Reviewed by Cursor Bugbot for commit 2cff0ed. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds a tool-calling mode to the evaluation harness: the response model is
given retrieval tools and decides what to fetch from Zep before answering,
alongside (or instead of) the deterministic context block.

- Tools are defined as ToolSpec entries in config/evaluation_config/tools.py
  (name, description, JSON Schema, async executor, enabled, requires_doc_graph)
- --tools/--no-tools is the only switch that decides whether they are used;
  --context-block/--no-context-block toggles deterministic retrieval, and the
  two combine
- Budgets: --max-tool-calls and --max-tool-iterations are enforced
  independently (parallel calls in one turn cost one iteration), with a forced
  answer turn once either is spent; --require-tool-call stops the model
  answering before it retrieves
- Completeness is graded on the union of every successful tool output, so it
  still measures retrieval while accuracy measures generation
- Latency reports overall user-facing latency plus tool wall-clock vs
  non-tool LLM time, with per-tool counts, failures, and cap/downgrade counters

Search knobs are now applied only where Zep honours them: max_characters for
scope="auto" (which ignores limit and reranker), limit and reranker for the
other scopes. Judge rubrics move to config/evaluation_config/judge_prompts.py
so every run snapshots the rubric that graded it; the completeness rubric
gained tool-awareness, so baselines should be re-run rather than compared
against older runs.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • Criticalzep-eval-harness/retry.py, is_retryable() hunk: treating every 404 as permanently non-retryable changes retry behavior repository-wide. Ingestion creates threads and immediately adds messages; an eventually consistent 404 will now abort that conversation instead of retrying, causing incomplete evaluation graphs. Restrict the new policy to the tool-agent request that needs it, or keep transient Zep 404s retryable.

  • Warningzep-eval-harness/tool_agent.py, new agent loop: no tests cover budget enforcement, parallel/refused calls, malformed arguments, provider tool_choice fallback, or final-answer retries. These paths directly determine retrieval results and metrics. Add focused async unit tests with mocked completion responses and tool executors.

Comment thread zep-eval-harness/zep_evaluate.py
Comment thread zep-eval-harness/config/evaluation_config/tools.py Outdated
… answer

The forced answer turn only refused ignored tool calls when the response had
no text. A provider that rejected tool_choice="none" and was retried with
"auto" can return both a "let me search..." preamble and tool calls, so that
preamble was graded as the final answer and the requested calls never reached
tool_trace.

Tool calls now take precedence over text on the final turn, exactly as they do
in the loop above: the calls are recorded as refused and the model is asked
once more. The first turn's text is kept only as a fallback when the re-ask
returns nothing, so a model that genuinely answers while also calling a tool
doesn't lose its answer.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — zep-eval-harness/tool_agent.py (new agent-loop implementation): No automated tests cover the new retrieval behavior. Budget boundaries, parallel calls, malformed/unknown calls, provider tool_choice downgrades, and forced-answer retries are complex and directly affect evaluation validity. Add unit tests with mocked completion/tool responses covering these branches before relying on reported completeness and tool metrics.

No other actionable correctness, security, or convention issues found.

Comment thread zep-eval-harness/tool_agent.py Outdated
…erankers

Two issues from review.

The forced-answer retry accepted its text without checking for tool calls, so a
provider that stalled twice ("still searching" plus another call) had that text
graded as the answer while the call went missing from tool_trace. The forced
answer is now a bounded loop where every attempt is validated the same way:
tool calls take precedence over text, each ignored call is recorded as refused
with its own round number, and if no attempt answers cleanly the run is flagged
with forced_answer_failed and counted as tests_forced_answer_failed. Text is
still kept as a last resort so a model that answers and calls a tool in the
same turn doesn't lose a real answer — the flag, not a blank answer, is what
records the failure.

Making the reranker configurable also advertised mmr and node_distance, which
cannot work as harness-wide defaults: Zep needs mmr_lambda for MMR and a
center_node_uuid to rerank around for node distance, and neither retrieval path
sends them. SUPPORTED_RERANKERS now lists only the argument-free rerankers
(rrf, cross_encoder, episode_mentions), a misconfiguration is rejected at
startup instead of failing on every search, and the README explains how to
evaluate the excluded two.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • zep-eval-harness/tool_agent.py (new agent loop): No automated tests cover tool-call parsing, parallel calls, invalid arguments, provider tool_choice fallback, call/iteration caps, or forced-answer behavior. These branches directly affect answers and evaluation metrics. Add focused async unit tests with mocked completion responses and tool executors, including exact-budget and over-budget cases.

Comment thread zep-eval-harness/tool_agent.py
The retry guard swept the whole 4xx range, which changed behavior for every
script that uses retry_with_backoff, not just the tool path it was added for. A
404 from a resource that isn't visible yet — a thread or graph just created by
the ingestion scripts — used to retry through and now would fail on the first
attempt.

Only 400 and 422 are terminal now: those are the malformed-request statuses the
guard exists to stop retrying (model-supplied search arguments), and no working
pipeline can depend on retrying them. Everything else, 404 included, retries
exactly as it did before.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — zep-eval-harness/tool_agent.py, new-file hunk @@ -0,0 +1,611: The complex agent loop has no automated tests. Budget enforcement, parallel calls, invalid arguments, provider tool_choice fallback, forced-answer retries, message/tool-call ID pairing, and metrics can regress silently. Add mocked async tests covering these paths, particularly call/iteration caps and valid follow-up message construction.

Comment thread zep-eval-harness/tool_agent.py
If one forced-answer attempt returned text plus tool calls and the next
returned neither, `text or preamble` promoted the earlier preamble ("Let me
search...") to the answer via the clean-turn break, so neither
forced_answer_failed nor answer_empty was set and the run looked like it had
answered.

The invariant is now explicit: the published answer either came from a turn that
answered cleanly with non-empty text, or forced_answer_failed is set. Falling
back to a preamble sets the flag wherever it happens, not only when every
attempt requested tools. The text is still published so a model that answers and
calls a tool in the same turn keeps its answer, and the counter is now documented
alongside the other tool-run counters.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — zep-eval-harness/tool_agent.py:393: The new multi-turn agent and budget-enforcement state machine has no automated tests. Regressions in parallel-call ordering, refused-call pairing, cap accounting, malformed arguments, tool_choice fallback, or forced-answer retries could silently invalidate evaluation results. Add mocked async tests covering normal completion and each budget/provider edge case.

Warning — zep-eval-harness/retry.py:8: HTTP 400/422 errors are now immediately non-retryable for every harness workflow, not only the new tool path, but this behavioral change is untested. Add tests proving transient statuses still retry, 400/422 fail immediately, and _chat performs its intended one-time tool_choice="auto" fallback.

Comment thread zep-eval-harness/config/evaluation_config/tools.py
…aths

The check ran over both rerankers unconditionally, so a tools-only run could be
rejected because CONTEXT_BLOCK_RERANKER was unusable even though no context
block search would happen, and a context-block run could be rejected over
TOOL_SEARCH_RERANKER. That coupled two modes whose whole point is to toggle
independently.

Validation now covers only the paths that will run, via unsupported_rerankers(),
which also makes the rule testable without the CLI. Confirmed both directions
end to end: with CONTEXT_BLOCK_RERANKER unusable, --tools --no-context-block
runs through to its first Zep call while the default context-block run is still
rejected with the same clear message.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — zep-eval-harness/tool_agent.py, new agent loop

The substantial tool-calling state machine has no automated tests. Budget enforcement, parallel calls, malformed arguments, missing call IDs, provider tool_choice downgrades, and forced-answer retries can silently corrupt evaluation results if they regress. Add unit tests with mocked completion responses and tool executors, especially for exact/overflowing call caps and repeated tool calls during forced-answer turns.

Warning — zep-eval-harness/config/evaluation_config/tools.py, _render_search_results()

Tool results tell the response model that facts with past end dates are “NO LONGER VALID,” while the completeness rubric explicitly says historical facts remain valid context. For historical questions, the agent may incorrectly discard the retrieved answer even though completeness marks retrieval successful. Reword the note to distinguish “not currently true” from “invalid for historical questions,” consistent with the judge prompt.

Comment thread zep-eval-harness/zep_evaluate.py
Comment thread zep-eval-harness/zep_evaluate.py
- Warming a document graph no longer sends CONTEXT_BLOCK_RERANKER, so a
  tools-only run can't fail on a constant its paths never use. Ranking is
  irrelevant to a limit=1 cache-priming search.
- Context block searches truncate the query like the tool searches already did.
  The limit is Zep's, not a tool policy, so TOOL_SEARCH_MAX_QUERY_CHARS is now
  SEARCH_MAX_QUERY_CHARS and both paths honour it; a long test question used to
  fail the whole search.
- scope="auto" tool output now carries the same temporal-validity note as every
  other scope and the context block. It was the default scope and the only path
  that never told the model a past end date means the fact no longer holds.
- require_tool_call is enforced, not just requested: if the provider ignores
  tool_choice="required" and answers on the first turn, the model is told to
  retrieve first and given one more turn (once, so a model that refuses can
  still finish). --no-require-tool-call still allows an immediate answer.
- Text from a turn that also requested tools now seeds the forced-answer
  fallback instead of being dropped, so a real answer isn't lost when the forced
  turns come back empty. It is still published only under forced_answer_failed.
- retrieval_configuration records tools_requested alongside use_tools, so
  --tools with every spec inactive is legible as "asked for, none active"
  rather than looking like a non-tool run.
- tests_with_no_calls is zero when tools were never offered, instead of counting
  every test in a context-block-only run.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • Warningzep-eval-harness/tool_agent.py, run_tool_agent loop hunk: The retry used to enforce require_tool_call consumes one of max_iterations, even though that turn made no tool call. With --max-tool-iterations 1, a provider that ignores/downgrades tool_choice="required" is prompted to retrieve again but immediately exits the loop and is forced to answer without retrieval. It also reports the next successful tool-call turn as iteration 2, contrary to the documented definition that an iteration is a turn requesting tools. Track tool-call rounds separately from LLM attempts, and allow the one enforcement retry without spending the retrieval-iteration budget.

  • Warningzep-eval-harness/tool_agent.py, _chat hunk: Every HTTP 400 from a constrained turn is treated as unsupported tool_choice and retried with "auto". A malformed message, excessive context, invalid tool schema, or other request error will therefore be misreported as a tool-choice downgrade and may relax required/forbidden tool use if the provider happens to accept the altered request. Only downgrade when the error specifically identifies tool_choice as unsupported; otherwise re-raise the original error.

  • Suggestion — The patch adds a complex provider-dependent agent loop without automated tests. Add focused tests covering parallel calls, invalid/refused calls, both budgets, providers ignoring or rejecting each tool_choice value, missing response content, forced-answer retries, and the --max-tool-iterations 1 case.

Comment thread zep-eval-harness/tool_agent.py Outdated
Comment thread zep-eval-harness/tool_agent.py
The first-turn nudge added in f156339 called _assistant_message() on the message
from _turn(), which is None when the provider returns no choices at all. That
raised AttributeError out of run_tool_agent and through the un-guarded gather in
evaluate_all_questions, aborting every remaining test case for the user. The
nudge now requires a message: with no choices there is no assistant turn to
replay and nothing to argue with, so it falls through to the forced-answer turn.

Also attributes tool_choice downgrades instead of only counting them. A 400 that
isn't about tool_choice fails the "auto" retry too and propagates, so it never
reached the counter — but the counter still mixed first-turn rejections with
forced-answer-turn ones. require_tool_call_unenforced now records specifically
that require_tool_call was asked for and not applied, whether the provider
rejected it or accepted it and answered without retrieving.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical

  • tool_agent.py, run_tool_agent() hunk: The for iteration in range(...) loop consumes an iteration even when the provider ignores tool_choice="required" and returns no tool call. With --max-tool-iterations 1, the corrective “retrieve first” message is appended, but the loop immediately ends and forces an answer without ever permitting retrieval. Larger budgets similarly lose one advertised retrieval round and misreport iteration-cap metrics. Retry the corrective turn without advancing the tool-iteration counter, and count only turns that actually request tools.

Warning

  • No tests accompany the new 668-line agent loop and its budget/provider-fallback behavior. Add focused async tests covering ignored/rejected tool_choice, parallel calls exceeding the call cap, malformed calls, forced-answer retries, and the one-iteration case above.

Comment thread zep-eval-harness/tool_agent.py Outdated
The first-turn nudge used `continue`, which advanced the iteration counter, so a
provider that ignores tool_choice="required" cost the agent one of its retrieval
rounds. With --max-tool-iterations 1 that was the whole budget: the nudge fired,
the loop ended, and the answer came from the forced turn with no retrieval at all
— the exact outcome the nudge exists to prevent.

The loop now counts rounds explicitly and the nudge gives its round back, since
it compensates for provider misbehavior rather than being retrieval the run asked
for. The nudge turn also drops tool_choice="required", which the provider has
already shown it won't honour.

hit_iteration_cap is now measured against rounds actually offered to the model
rather than result.iterations, which counts only rounds that requested tools — a
round the model spent answering still consumed the budget.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • zep-eval-harness/tool_agent.py:443 — The new 676-line tool-calling state machine has no automated tests. Budget exhaustion, parallel calls, malformed arguments, tool_choice downgrades, and forced-answer retries materially affect correctness. Add unit tests using fake completion/tool responses, especially verifying message pairing and call/iteration caps.

No additional actionable correctness or security defects found.

Comment thread zep-eval-harness/tool_agent.py
… one

The forced-answer loop treated a response with no choices as a clean turn: text
was "", tool_calls was empty, so it took the accept-and-break path, published an
empty answer, and skipped its remaining attempt. The retrieval loop already
treats a missing message as nothing to accept; the forced loop now applies the
same rule and spends another attempt, which is worth doing because an empty
candidate is often transient.

If no attempt ever answers, forced_answer_failed is set as before (answer_empty
covers the case where there was no text at all), and the log line no longer
claims the model "kept requesting tools" when the provider simply returned
nothing. The instruction is also not re-appended when the previous attempt
produced no message, so the history doesn't repeat it back to back.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning — zep-eval-harness/tool_agent.py (new agent loop): No automated tests cover the new 686-line tool-calling state machine. Budget enforcement, parallel calls, malformed arguments, provider tool_choice downgrades, and forced-answer retries can silently alter evaluation results. Add unit tests with mocked OpenAI responses and tool executors covering these branches before relying on the reported metrics.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e865e02. Configure here.

Comment thread zep-eval-harness/tool_agent.py Outdated
… a block

The first-turn nudge asserted "you have not retrieved anything yet, and you have
no knowledge of this user" — false in "both" mode (--tools with the context block
on), where the system prompt already carries a full context block and the
completeness judge grades that block. It also spent a turn pushing the model
toward a tool call it did not need, on the default tool-mode configuration.

run_tool_agent now takes has_context_block. With a block injected, answering the
first turn without a tool call is legitimate and the answer stands; without one,
nothing else grounds the answer and the nudge fires as before, so the instruction
is only ever sent when its claim is true.

require_tool_call_unenforced is still recorded in both modes — it is a fact about
the provider, not a claim about groundedness — and the docs now say so, since in
"both" mode the context block did the grounding.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Warning — zep-eval-harness/tool_agent.py (new agent loop, especially run_tool_agent): The 699-line provider-facing state machine has no automated tests. Budget exhaustion, malformed/unknown calls, parallel calls, ignored tool_choice, missing choices, and forced-answer retries can silently alter answers and evaluation metrics. Add mocked async tests covering these branches and verifying message history, counters, and retrieved context.

  • Suggestion — zep-eval-harness/README.md, results JSON example around the require_tool_call_unenforced fields: The example is invalid JSON: it contains a trailing comma and an apparently duplicated/mis-indented tests_require_tool_call_unenforced entry. Correct the example so users can parse or copy it reliably.

@jackaldenryan
jackaldenryan merged commit d2dad6e into main Jul 26, 2026
11 checks passed
jackaldenryan added a commit that referenced this pull request Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant