Repository navigation
feat(AIC-3439): let wildcard handlers accept only named providers - #136
andrewklatzke wants to merge 3 commits into
Conversation
A LangChain adapter used for one provider no longer has to match every provider, and two scoped wildcards of the same mode can be registered together. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Human message: I think this is a "nice to have" not necessarily "must do it". I'm thinking of situations where, for example, a user might want to use the Google ADK and scope it only to gemini ( You can technically achieve this now by properly including the right subset of handlers, but this makes it more obvious/colocates it with the code if you want to scope them down. tldr; if we think this is confusing or don't want it, we can close it |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ee8f6f0. Configure here.
jeffdupont
left a comment
There was a problem hiding this comment.
The routing code is close, and it matches js #111 on almost every probe case. This PR also fixes a Python bug. On main, run_judges used the parent handler for any judge whose provider no handler covered, because the no-match branch fell through to judge_handler = handler (main judges.py:110-147). An OpenAI parent would run an Anthropic judge. build_judge_tasks already skipped that case. Two things need doing before this merges: a rebase that fixes two tests, and agreement with #118 on how the evaluations runner picks handlers.
At ee8f6f0c I get 1427 passed and 11 skipped. mypy (51 files), ruff and ruff format --check are clean.
- Two new tests fail once this is merged with today's
main. Git merges it without conflicts. Buttest_scoped_wildcard_does_not_run_a_judge_outside_its_listandtest_two_scoped_judge_handlers_divide_providers(test_evaluations_run.py:2100 and :2133) callinit_evaluations(api_token=...)and passproject_keytorun(). Since this branch was cut,mainhas renamed the argument toapi_key(7a238e5) and movedproject_keyontoinit_evaluations(I found #127 / be1e64a withgit log -S, but didn't read the commit). After I fixed both calls, the merged tree gives 1505 passed and 11 skipped, with mypy (52 files) and ruff clean. The green CI ran on the old base. - #118 conflicts with this, in the code and in what it does. Merging this into #118 (
4cd1e0ee) conflicts inevaluations/runner.pyandpackages/client/README.md. #118 replaces_find_judge_handlerand_covers_providerwith a single_select_handler, and its rules contradict this PR in two ways. First, "a single handler always runs, whatever it declares", so a lone Bedrock-scoped handler would run an Anthropic judge. Second, two wildcard candidates raise "can't disambiguate" instead of the shortest list winning. Whichever PR lands second has to rebuild the scoped order inside_select_handler. I'd land this one first and have #118 callselect_mode_handler. #39 §3.13 already says that a judge outside every scoped list raisesEvaluationsErrornaming the provider. - Bugbot's untyped-parent finding is real, but I'd keep the new behaviour. I reproduced it. With
run_judges(handler=untyped, handlers=[untyped]), the judge ran onmainand is skipped now.graph()picks a lone untyped handler through the non-strict fallback, so that node's judges are now dropped without a log line. Butmain'sbuild_judge_tasksalready skipped the same case (0 tasks in my probe), and JS skips it too, becauseparentCoversis false withoutprovidesFor(judges.ts:25-28). This samecontinueis also what stops an OpenAI parent from running an Anthropic judge. So the change makes Python consistent with itself and with JS. I'd ask for two things: log when a judge is skipped because no handler fits (neither SDK says anything today), and add a changelog line. The evaluations runner still treats an untyped generation handler as covering every judge (runner.py:179-180). That's a separate path and fine as it is, but #39 should say so. - Python replaces a re-registered handler in place, and JS moves it to the end. registry.py:74 keeps the slot, while js registry.ts:29 filters and appends. Ties go to the earlier registration, so the SDKs now choose differently. As a repro, register
['Bedrock', 'Gemini'], then['Bedrock', 'Anthropic'], then['Bedrock', 'Gemini']again. Python then runs the Gemini-list handler for Bedrock, and JS runs the Anthropic-list one.composediffers the same way. We need one rule in #39 §3.6. I'd keep Python's. select_handlernow drops matches that aren'tProviderHandlerinstances (utils.py:601). Onmain, the exact and wildcard steps returned any object withprovides_for. Nowisinstance(chosen, ProviderHandler)throws the match away, andconfig()raisesHandler for provider OpenAI with mode messages not foundeven though a matching handler is registered. I probed this with a duck-typed('*', 'messages')object. If onlyProviderHandleris supported, the error message is misleading. Otherwise, drop the check and use acastfor mypy.- Repeated names count toward list length (utils.py:97).
frozensetmakes['Bedrock', 'Bedrock']the same identity as['Bedrock'], butbest_wildcardranks it bylen(tuple(allow)), so it ties with['Bedrock', 'Anthropic']. Deduping innormalize_providersfixes this. JS has the same issue. - The docstring and the code disagree on whitespace. utils.py:26-28 says surrounding whitespace is rejected rather than trimmed, but
[' Bedrock']is accepted (and never matches). Either reject it or fix the docstring. JS needs the same change.
I tried 23 mutations, and 17 of them fail a test. The 6 that get through are in the inline comments: unscoped beating scoped, the strict coverage check, _provider_matches, the runner's _covers_provider and its shortest-list sort, and accepting a bare string.
Smaller items
ProviderHandler.__init__takesproviderspositionally and doesn't validate or copy it (types.py:169, :175).ProviderHandler(fn, pf, providers=[])builds a handler that matches nothing. Please run it throughnormalize_providers, or make it keyword-only. This signature freezes at 1.0 too.- Exact handlers that have a list keep a separate identity (registry.py:20). The list is ignored for routing on an exact handler, so two
('OpenAI', 'messages')handlers should collide, as #39 says. JS does the same. - The duplicate warning prints a
frozensetrepr:Handler for (('*', 'messages'), frozenset({'Gemini', 'Bedrock'})) already registered. The name order changes from run to run. Sorting the names for the message would fix that. - Public surface.
providersis a keyword-only argument oncreate_handler,create_langchain_messages_handlerandcreate_langchain_agents_handler, and an attribute onProviderHandler, with the same name and meaning as JS. No new names reach__all__(normalize_providers,wildcard_covers,best_wildcard,select_mode_handlerandcovers_provider_namearen't exported), so py #144'sROOT_SURFACEsnapshot isn't affected. - #107 overlap. Merging this into #107 (
c76acd58) conflicts in utils.py, wheremodel_parametersand the new helpers are both additions, and in thecollections.abcimport line of both LangChain handlers (MappingvsSequence). Keep both sides. I don't see a clash in behaviour: #107 is about parameter forwarding, not routing. graph_judgepasseshandlershere (graph.py:1016-1024 and :1238-1250), which is right. JS doesn't, so I've asked for that on js #111.
Parity with js-ai-sdk #111
The two SDKs agree on selection order, ties, case, a None / "" provider, empty and blank lists, both strict error strings (Handler for provider OpenAI not found and ... with mode agent not found), and set identity. They differ in four places:
- re-registration position (4)
- the graph-level judge (JS is wrong)
- copying the caller's list (Python copies, JS doesn't)
['a,b']vs['a', 'b'], which collide in JS and stay separate here
…ped-handlers Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # packages/client/src/launchdarkly_ai_server/types.py # packages/client/src/launchdarkly_ai_server/utils.py # packages/langchain-agents/src/launchdarkly_ai_langchain_agents/graph.py # packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py # packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py
…ct handler identity - Reject provider names with surrounding whitespace; dedupe and copy the list. - A judge with no covering handler is skipped with a warning. - An empty handlers list no longer bypasses the scoped-parent check. - Exact handlers collide on (provider, mode) whatever their list. Co-authored-by: Cursor <cursoragent@cursor.com>

Summary
providerslist, so a LangChain adapter used only for Bedrock acceptsconfig.provider.name == "Bedrock"and no other provider.[provider, mode]still wins. Among scoped wildcards that contain the name, the shortest list wins, then the earlier registration. An unscoped*is the fallback.provides_forplus the provider set, so two scoped wildcards of the same mode can be registered together. Judges, graph selection, and the evaluations runner use the same order.Test plan
uv run pyteston the client, langchain-messages, and langchain-agents suitesruffandmypyon the touched packagesMade with Cursor