Conversation
There was a problem hiding this comment.
Note
Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.
Devin Review found 2 potential issues.
3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| try: | ||
| raw = require_mapping(self._api.get(path), description=f"tool {key!r}") |
There was a problem hiding this comment.
🔴 Library tool lookup blocks event loop
When async callers invoke ToolsClient.get, its synchronous GET blocks their event loop. Previously run moved tool reads to a worker thread, so slow lookups now stall unrelated requests.
Learn more
The management API client uses a synchronous urllib transport with a 30-second timeout and retry delays LDApiClient. The previous tool-resolution call ran inside asyncio.to_thread in run(). With the new public ToolsClient.get, an application calling it from an async request or async startup blocks the event-loop thread while the GET waits and retries. No other coroutine on that loop can progress until the lookup returns.
Example: An async request calls evals.tools.get("lookup_order", implementation=lookup_order) while the management API takes 30 seconds to respond. Every other request on the same loop waits those 30 seconds, even if it does not use evaluations.
Recommended fix: Offer an awaitable tool lookup that delegates the synchronous request to asyncio.to_thread, or implement an asynchronous management transport. Keep the version pinning and immediate error behavior when callers await the lookup.
Was this helpful? React with 👍 or 👎 to provide feedback.
Add Tool. Construct one to define a tool in code. A constructed Tool is always inline, because source and version are not constructor arguments. Add evals.tools.get(key, implementation=...). It reads the library tool now, pins the version now, and raises now when the tool is absent. It is the only way to make a library tool. run() reads no tool from the API. run(tools=...) now takes a list of Tool. Each Tool carries its own key. Validate the list before any network I/O. Reject a non-Tool entry, a blank or uppercase key, a non-object or non-serializable schema, a non-callable implementation, a repeated key, and a NativeTool on an inline tool. Compare keys without case. 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. Give tools their own module. evaluations/tools.py owns Tool, the validation, the projections to a handler config and to a create body, and ToolsClient. ToolsClient reads the library itself, so the runner does not. Move segment(), require_mapping(), and require_string() to api.py. The create body and the event payloads do not change. Spec: launchdarkly/ai-sdks-monorepo#24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e09e7fd to
df02eec
Compare
The option is api_key. The error message and the README still called the credential an API access token. The LD_API_TOKEN variable name does not change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A caller who passes tools= replaces the variation's list, so an empty list runs the variation with no tools. The check rejected every variation tool that the list omitted, which made that impossible. It now raises only when the caller passes no tools at all, and logs a warning for each variation tool the run does not use. Record the project a library tool was read from, and reject a tool that came from a different project than the run. The handler would otherwise use one project's schema while the record named another project's tool. Say in the docstring and the README that tools.get() blocks, so a caller does not run it inside an event loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| @@ -58,15 +58,6 @@ class DatasetRow: | |||
|
|
|||
|
|
|||
| @dataclass | |||
There was a problem hiding this comment.
[nit] is this decorator redundant given the one beneath?
jeffdupont
left a comment
There was a problem hiding this comment.
Reviewed with the 1.0 freeze in mind, against the spec in launchdarkly/ai-sdks-monorepo#24 (my review there covers the spec side). Tests pass at a9c40bc: make test 1417 passed, 11 skipped, exit 0, and make typecheck and make lint are clean. Merged onto current main (fee904a, 7 commits past your base), it merges cleanly and still passes: 1421 passed, typecheck clean.
The behaviour matches #24 everywhere I checked. run() issues no /ai-tools request, tools.get() pins the version and records the project, the create-body shapes are right, the cross-project guard works, and bad lists fail with zero requests. There's no JS counterpart. That's expected, since the GA plan defers evals-from-code in JS past 1.0.
Four things I'd like settled before GA, because each one is a public signature:
- The root name
Tool. It's new in__all__, and in JS the rootToolis already the AI Config tool definition (index.ts:78). So both SDKs would export a rootToolwith different meanings, and Python couldn't later add the AI ConfigTool(types.py:79) under its JS name. I'd rename itEvalTool. Separately, my monorepo #33 moves the evaluations names tolaunchdarkly_ai_server.experimental.evaluations, so adding a root name now means one more to move later. Toolis mutable, so "constructed means inline" doesn't hold. Reproduced at a9c40bc:t = Tool(key="search_docs", implementation=f); t.source = "library"; t.version = 99passesvalidate_tools([t], "proj")and sends{"key": "search_docs", "version": 99, "source": "library"}. Itsproject_keyisNone, and the guard attools.py:150-153skips that case. Making the classfrozen=Trueafter 1.0 would break anyone who assigns to it, so now is the time. Inline below.tools.get()blocks the event loop. Devin raised this. The docstring now says not to call it inside a running loop, butrun()is async, so that's exactly where callers will be, including the README example. Making itasynclater is a breaking change. I'd make it awaitable now.schemais optional here but required in the spec.Tool(key="a", implementation=f)validates and sends"schema": {}. §8.4 says a missing schema throws. Requiring it later is breaking, while relaxing it later is additive, so I'd drop the default.
Smaller notes:
- This breaks existing callers:
run(project_key=...)is gone,init_evaluations(api_token=...)is nowapi_key=with no alias, andtoolswent from a map to a list. All of that is fine before 1.0. But the PR title has no!, and squash merges here use the PR title and body as the commit, so release-please won't flag the break in the changelog.feat(evaluations)!: ...would. The title also still says "inline tool definitions", which undersells the change. - The description still describes the earlier
InlineTool-in-a-map design: the reviewer decision, the test names, the 1318-test count. It becomes the squash commit body, so it's worth refreshing. packages/client/README.md: the hunk at line 165 replaces the lazy-initialization section and theinit_client/get_client/shutdown/inspect_configtable instead of adding beside them. That looks accidental. The same README still passesproject_key=torun()at lines 64 and 100, which now raisesTypeError, and line 81 still says "project_keyis supplied per run rather than during initialization".- Interaction with #127 (eval rows defined in code): both PRs change
run()'s signature, and merging the two heads conflicts in__init__.py,api.py,module.pyandrunner.py(git merge-tree). #127 keepsproject_keyonrun()and names its typeInlineDatasetRow, while this PR dropsInlineToolin favour of "constructed means inline". I'd agree one naming convention for both before either merges. - Release order: inline entries and
sourcedepend on gonfalon#72707, which is approved but not merged. I haven't checked how the current API treatssourceon a library entry, so I can't say whether library-only runs keep working if this ships first. - The lowercase check also runs on
tools.get(), so a library tool the API accepted asSearch_Docscan't be used from code. More on that on #24. dataclasses.replace(library_tool, implementation=g)returns an inline tool carrying the library schema (source,versionandproject_keyreset because they'reinit=False), and that skips the project guard. It's rare, but worth a line in the docstring.- On aknight's
types.py:60nit: the extra@dataclassleavesAIConfig.__dataclass_params__.frozenasFalse. Instances still refuse assignment and stay hashable (I checked), so deleting the line is safe.
| "EvaluationsError", | ||
| "EvaluationsModule", | ||
| "GenerationConfig", | ||
| "Tool", |
There was a problem hiding this comment.
Adding Tool here freezes it at 1.0. In JS, the root Tool is the AI Config tool definition (index.ts:78, types.ts:88). Python has the same type unexported at types.py:79. If this ships as is, the same root name means two different things across the SDKs, and Python can't adopt the JS name later. EvalTool would avoid both problems. If monorepo #33 lands, this moves to experimental.evaluations anyway.
| implementation: ToolImplementation | ||
| schema: dict[str, Any] = field(default_factory=dict) | ||
| description: str = "" | ||
| source: Literal["library", "inline"] = field(default="inline", init=False) |
There was a problem hiding this comment.
init=False keeps these out of the constructor, but a caller can still assign to them. t.source = "library"; t.version = 99 on a constructed tool passes validate_tools and goes out as a library entry (reproduced). I'd make the class @dataclass(frozen=True) and set these three in _library with object.__setattr__. That also stops schema being reassigned after validation, though the dict itself can still be mutated in place.
| _validate_inline_tool(tool) | ||
| elif ( | ||
| project_key is not None | ||
| and tool.project_key is not None |
There was a problem hiding this comment.
A library tool always has a project_key when it comes from tools.get(), so None here means the tool was built some other way. Skipping the check in that case is what lets a forged library tool through. I'd treat source == "library" with no project_key as an error, and drop the project_key is not None condition on the module side too, since run() always passes one.
| self._api = api_client | ||
| self._project_key = project_key | ||
|
|
||
| def get(self, key: str, *, implementation: ToolImplementation) -> Tool: |
There was a problem hiding this comment.
Callers are inside an event loop when they set up a run, because run() is awaited, and this blocks the loop for the 30 s timeout plus up to 3 retries. async def get(...) that does await asyncio.to_thread(self._api.get, path) keeps the same validation-first behaviour and matches what run() already does for every other management call. That's easy to change now and breaking after 1.0.
|
|
||
| key: str | ||
| implementation: ToolImplementation | ||
| schema: dict[str, Any] = field(default_factory=dict) |
There was a problem hiding this comment.
§8.4 in monorepo #24 says schema is required on an inline tool, with {} legal but absence not. With this default, Tool(key="a", implementation=f) is accepted and sends "schema": {}. _library always passes schema, so making it required (no default) only affects inline construction. description keeps its default.
| Judges are resolved through flag delivery, and handlers are matched to them, **before** any evaluation records are created — a missing judge or one no handler covers fails the run up front rather than after the generation spend. After that point a criterion failure never aborts the run: an unparseable judge response, an out-of-range score, a raising handler or scorer, and a row whose generation errored each become a per-criterion `ERROR` event with a cause code (`invalid_judge_output`, `invalid_score`, `handler_raised`, `scorer_raised`, `generation_incomplete`) and a top-level `errorMessage`. Event *delivery* is different: the backend needs one result per `(row, criterion)` to finish row accounting, so if tracking a criterion event fails, every remaining result is still attempted and flushed and then `run()` raises — rather than polling to its timeout with the cause hidden. | ||
|
|
||
| The client uses **lazy initialization**: importing the package does not connect to LaunchDarkly. The singleton is created automatically on the first API call that needs it (`config().invoke()`, `graph().invoke()`, `resolve_graph()`, etc.), as long as `LD_SDK_KEY` is set in the environment. | ||
| ### Give the evaluation tools |
There was a problem hiding this comment.
This hunk replaces the "lazy initialization" section and the init_client / get_client / shutdown / inspect_config table that were here on main. They aren't anywhere else in the file now. I think the new section was meant to go next to them, not in their place. Separately, lines 64 and 100 still pass project_key= to run(), and line 81 says it's supplied per run.
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 thetoolsmap to resolve viaGET projects/<p>/ai-tools/<key>, and that response is the only source for the handler config's tooldescriptionandparameters. There is no slot in the public API for a caller-supplied body.Changes
InlineTooltype carryingdescription,schema, and the executable (implementation), accepted in the sametoolsmap 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_toolsskips theai-toolsGET for inline entries._validate_tools, called fromrun()ahead of every request): key non-blank,schemaa JSON object and JSON-serializable withallow_nan=False, implementation callable, no duplicate keys across sources, and an inline key that also names a library tool is rejected naming the key.{key, schema, description, source: "inline"}for inline entries and{key, version, source: "library"}for library entries.toolsis still omitted entirely when the map is empty.config["tools"][key] = {description, parameters}is fed from the inline body instead of the GET response, and anInlineToolis unwrapped to its executable before any handler sees it, so handlers see no difference.Decision for reviewers
Whether a
NativeToolvalue 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
toolsmapping, a plaindictcannot express the same key twice, so those checks needed a concrete surface. Implemented as: (a) the map is validated as the caller'sMappingbefore it is copied into adict, so aMappingthat 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'smake typecheck, which CI mirrors) — no issues in 50 source files. Note the repo does not type-check test suites understrict, perAGENTS.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 synthesizedconfig["tools"], and that the handler receives the bare callable.test_inline_tool_description_defaults_to_empty_stringtest_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,schemaas a list, non-serializableschema,NaN,Infinity, non-callable implementation, non-string description, and an entry that is none of the three. Each assertstransport.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 forNativeToolis 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 customMappingthat yields one key twice.One existing assertion changed: the happy-path create body in
test_complete_run_with_zero_failed_and_error_rows_passesnow 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
sourcediscriminator 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
Note
Overview
Evaluation runs now take
toolsas a list of exportedToolobjects instead of akey → callablemap. You canTool(...)for inline definitions (schema + description in code, no LaunchDarkly AI library entry) orevals.tools.get(key, implementation=...)to fetch a library tool once, pin its version, and pass it torun()—run()no longer resolves tools via the management API.init_evaluations(project_key=...)(orLD_PROJECT_KEY) binds the project for datasets, evaluations, andtools.get();project_keyis removed fromrun(). Evaluation-create payloads distinguishsource: "inline"vssource: "library".validate_toolsruns before any I/O (keys, schemas, duplicates,NativeToolonly on library tools). The management client parameter is renamedapi_key(still envLD_API_TOKEN). Runner_resolve_tools/ResolvedToolare removed in favor of the newevaluations/tools.pyhelpers.Reviewed by Cursor Bugbot for commit a9c40bc. Bugbot is set up for automated code reviews on this repo. Configure here.