Skip to content

feat(evaluations): accept inline tool definitions in run(tools=...) - #105

Draft
donei003 wants to merge 5 commits into
mainfrom
doneill/inline-eval-tools
Draft

donei003 wants to merge 5 commits into
mainfrom
doneill/inline-eval-tools

Conversation

@donei003

@donei003 donei003 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Lets a developer define a tool in code instead of creating it in LaunchDarkly first.

_resolve_tools (evaluations/runner.py) currently requires every key in the tools map to resolve via GET projects/<p>/ai-tools/<key>, and that response is the only source for the handler config's tool description and parameters. There is no slot in the public API for a caller-supplied body.

Changes

  • New exported InlineTool type carrying description, schema, and the executable (implementation), accepted in the same tools map as today's bare callables. A bare callable still means "library tool, resolve by key" — no change for existing callers, and a map may mix both sources.
  • _resolve_tools skips the ai-tools GET for inline entries.
  • Validation before any network I/O (_validate_tools, called from run() ahead of every request): key non-blank, schema a JSON object and JSON-serializable with allow_nan=False, implementation callable, no duplicate keys across sources, and an inline key that also names a library tool is rejected naming the key.
  • Eval create body sends {key, schema, description, source: "inline"} for inline entries and {key, version, source: "library"} for library entries. tools is still omitted entirely when the map is empty.
  • Handler config synthesis is unchanged in shape — config["tools"][key] = {description, parameters} is fed from the inline body instead of the GET response, and an InlineTool is unwrapped to its executable before any handler sees it, so handlers see no difference.

Decision for reviewers

Whether a NativeTool value may be paired with an inline definition. A native tool has no schema of its own, so I've made the combination a validation error rather than inventing a meaning for it.

One other thing worth a look: the brief asked for "no duplicate keys across sources" and "an inline key that also names a library tool is rejected". Because both sources share one tools mapping, a plain dict cannot express the same key twice, so those checks needed a concrete surface. Implemented as: (a) the map is validated as the caller's Mapping before it is copied into a dict, so a Mapping that yields a key twice is caught rather than silently collapsed; and (b) keys are compared case-insensitively only when an inline definition is involved — {"lookup_order": fn, "Lookup_Order": InlineTool(...)} is rejected naming the key, while two library keys differing only by case remain two library lookups, since changing that would alter existing library behavior. Both are tested. Say the word if you'd rather the collision rule were exact-case only, or extended to library/library pairs.

Verification

Run in a clean worktree off origin/main:

  • uv run pytest — 1318 passed, 11 skipped (full monorepo suite).
  • uv run ruff check . — all checks passed.
  • uv run ruff format --check . — 114 files already formatted.
  • uv run mypy packages/*/src (the repo's make typecheck, which CI mirrors) — no issues in 50 source files. Note the repo does not type-check test suites under strict, per AGENTS.md, so the new tests are not mypy-covered.

New tests in packages/client/tests/test_evaluations_run.py:

  • test_inline_tool_runs_without_reading_the_tool_api — asserts the inline request sequence in full as (method, path) pairs, and asserts explicitly that no recorded URL contains /ai-tools, rather than relying on the strict-sequence transport to trip on a surplus request. Also asserts the create body's inline entry, the synthesized config["tools"], and that the handler receives the bare callable.
  • test_inline_tool_description_defaults_to_empty_string
  • test_mixed_library_and_inline_tools_each_keep_their_own_source — exactly one tool GET, for the library key only; both wire entries; both config entries.
  • test_bad_tool_entry_is_rejected_with_zero_requests — parametrized over blank inline key, blank library key, schema=None, schema as a list, non-serializable schema, NaN, Infinity, non-callable implementation, non-string description, and an entry that is none of the three. Each asserts transport.requests == [].
  • test_native_tool_paired_with_an_inline_definition_is_rejected — zero requests.
  • test_native_tool_on_its_own_still_resolves_from_the_library — the library path for NativeTool is unchanged.
  • test_inline_key_that_also_names_a_library_tool_is_rejected — zero requests.
  • test_two_library_keys_differing_only_by_case_are_still_two_lookups — pins the library path against the new collision rule.
  • test_duplicate_tool_key_across_sources_is_rejected_with_zero_requests — uses a custom Mapping that yields one key twice.

One existing assertion changed: the happy-path create body in test_complete_run_with_zero_failed_and_error_rows_passes now expects {"key": "lookup_order", "version": 7, "source": "library"}.

CI on this branch is green: Lint & format, Type check, Tests, Build all packages, Install & sync all pass.

Not verified: nothing was exercised against a real gonfalon proxy or ai-evaluator — the source discriminator and the inline body shape are asserted against the recording fake transport only.

Dependencies

Requires the gonfalon proxy/API change deployed. Cross-language spec is ai-sdks-monorepo TESTING.md §8 (spec PR open concurrently).

🤖 Generated with Claude Code

donei003 and others added 5 commits September 23, 2026 14:35
A tool had to exist in the LaunchDarkly AI library before an offline
evaluation could use it: every key in run(tools=...) was resolved with
GET projects/<p>/ai-tools/<key>, and that response was the only source
for the handler config's tool description and parameters.

InlineTool carries a tool's schema, description, and executable in code.
It goes in the same tools map as today's bare callables, which still mean
"library tool, resolve by key", so a map may mix the two and existing
callers are unaffected. _resolve_tools issues no request for an inline
entry; the evaluation-create body carries a source discriminator so the
record shows which identity each tool has.

Handler config synthesis is unforked -- config["tools"][key] =
{description, parameters} is fed from whichever source resolved the tool,
and an InlineTool is unwrapped to its executable before any handler sees
it, so handlers cannot tell the two apart.

Inline definitions are held to a stricter standard than server-fetched
ones, and every check runs before any network I/O: a bad inline value is
a caller bug that is cheapest to reject while no record exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tool key does not use uppercase letters. An inline key is the caller's
own, so run() rejects one that carries an uppercase letter and names the
key. The API key pattern is laxer and still admits one, so this rule is
stricter than the server on purpose.

The rule also makes the case-insensitive collision check safe. No valid pair
can now differ only in case.

Change the shape of the collision test. It put the inline definition on the
uppercase key, which the new rule rejects first. The uppercase message also
names both spellings, so the old pattern still matched and the test stopped
covering the collision. The library key now carries the uppercase letter,
which is the only pair that can still reach the check, and the pattern
asserts the collision wording.

Spec: launchdarkly/ai-sdks-monorepo#21

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This text is customer facing. Remove the event key names and the statement
that the harness emits events. Say instead that the harness needs an
initialized client to send results.

Remove LD_API_BASE_URI and LD_UI_BASE_URI. A customer does not need either
variable.

State that LD_SDK_KEY is required. The old text said to configure it, which
reads as optional. init_evaluations() raises without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comments in this repo are public. Remove the design rationale from the tool
comments and docstrings. Each one now states what the code does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the tools mapping with a list of Tool. One Tool carries its own key.

Add evals.tools.get(key, implementation=...). It reads the library tool now,
pins the version now, and raises now when the tool is absent. run() reads no
tool from the API.

A constructed Tool is always inline. source and version are not constructor
arguments, so only tools.get() can produce a library tool.

Move project_key to init_evaluations(). run() no longer takes it. Read
LD_PROJECT_KEY when the argument is absent.

Rename the api_token argument to api_key, in init_evaluations() and in
LDApiClient. The LD_API_TOKEN variable name does not change.

Apply the lowercase key rule to every tool key, not only an inline one. A
library key now comes from tools.get(), which shares the same check. The
collision check compares keys without case. Two keys that differ only by case
can no longer both be valid, so the earlier asymmetry is gone.

The create body and the event payloads do not change.

Spec: launchdarkly/ai-sdks-monorepo#21

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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