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.
e09e7fd to
df02eec
Compare
| @@ -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.
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>
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>
….get async Rename the exported Tool to EvalTool so the root name does not clash with the AI Config Tool type. Make EvalTool frozen, so a constructed tool cannot become a library tool. Require schema on every tool. Reject a library tool that has no project. Make ToolsClient.get a coroutine. It reads in a worker thread and does not block the event loop. Restore the client README lazy-initialization section. Remove project_key from the run() examples. Remove a duplicate dataclass decorator. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
f1c4981 to
4b8a819
Compare
This change lets a developer define a tool in code. The developer does not need to create it in LaunchDarkly first. It also changes the public API of
run()andinit_evaluations(). This is a breaking change.Breaking changes
toolsis now a list ofEvalTool. It was akey -> callablemap.project_keyis no longer an argument ofrun(). Set it ininit_evaluations(project_key=...)or withLD_PROJECT_KEY.init_evaluationsargumentapi_tokenis nowapi_key. There is no alias. The environment variable is stillLD_API_TOKEN.Changes
EvalTool. It is a frozen dataclass withkey,implementation,schemaanddescription.schemais required.{}is valid.descriptiondefaults to an empty string.source,versionandproject_keyare not constructor arguments. A constructedEvalToolis always inline. Onlyevals.tools.get()makes a library tool.await evals.tools.get(key, implementation=...)reads a library tool, pins its version, and records its project. The read runs in a worker thread and does not block the event loop.run()sends no request toai-tools. A library tool is read once bytools.get().validate_toolsruns before any network I/O. It rejects a blank or uppercase key, aschemathat is not a JSON object, aschemathat is not JSON-serializable (NaNandInfinityincluded), a non-callable implementation, a repeated key (compared without case), aNativeToolon an inline tool, a library tool with no project, and a library tool from a different project.{key, schema, description, source: "inline"}for an inline tool and{key, version, source: "library"}for a library tool.toolsis omitted when the list is empty.tools=[]replaces the tools of an AI Config variation.tools=Noneuses the variation tools, and each one needs an implementation.{key: callable}map for both kinds of tool.run()examples no longer passproject_key.Verification
uv run pytest: 1421 passed, 11 skipped.uv run ruff check .anduv run ruff format --check .: clean.uv run mypy packages/*/src: no issues in 52 source files.I did not test against a real gonfalon proxy or ai-evaluator. The
sourcefield and the inline body are asserted only against the recording fake transport.Dependencies
TESTING.mdsection 8.🤖 Generated with Claude Code
Note
Overview
This is a breaking refactor of the evaluations harness API for tools and project scoping.
init_evaluationsnow requiresproject_key(orLD_PROJECT_KEY) and renamesapi_token→api_key(env var staysLD_API_TOKEN).run()no longer acceptsproject_key.Tools move from a
dict[str, callable]to alist[EvalTool]. New exportedEvalToolsupports inline definitions (key, implementation, required JSON schema, optional description) without a LaunchDarkly AI library entry.await evals.tools.get(key, implementation=...)fetches library tools, pins version, and runs the GET in a worker thread.run()does not call the tool API; library tools must be resolved viatools.get()first.Validation runs before any network I/O (keys, schemas, duplicates, cross-project library tools,
NativeToolonly on library tools). Evaluation-create payloads sendsource: "inline"with schema/description orsource: "library"with version.tools=[]replaces an AI Config variation’s tools; omitting tools still requires implementations for variation-attached tools.Docs and tests are updated throughout;
LDApiClientusesapi_keyinternally.Reviewed by Cursor Bugbot for commit 4b8a819. Bugbot is set up for automated code reviews on this repo. Configure here.