Skip to content

fix(marketplace, extension-manager): rank a multi-word marketplace query (F5); an empty list_resources explains itself (F8) - #242

Merged
Broccolito merged 4 commits into
mainfrom
claude/nervous-cohen-475763
Sep 11, 2026
Merged

fix(marketplace, extension-manager): rank a multi-word marketplace query (F5); an empty list_resources explains itself (F8)#242
Broccolito merged 4 commits into
mainfrom
claude/nervous-cohen-475763

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Why

Two findings from the 2026-09-10 composer-driven QA run on merged main 7c96d79
(~/biorouter-runs/test-drive/qa-f/report.md, F5 at lines 796-805 and F8 at 825-829; the
measurements are checks 7 and 8, lines 297-335 and 354-385).

F5 (MEDIUM): the marketplace search did not tokenise a multi-word query. In one chat, against one
live registry (source: "live", stale: false):

Query Total
R scripting ggplot visualization 0
ggplot 2
r-scripting 1
(empty) 129

The model reported "no matching marketplace skills found". That was false: the registry held every
skill the phrase names.

F8 (LOW): extensionmanager__list_resources returned "". The model could only report that
"it returned an empty string". Every other empty or refusing result in that run explained itself.

Root cause

  • F5. marketplace.rs's searchable() asked whether the WHOLE lowercased query was a
    substring of one field. A single word worked, and any phrase failed. search_skills and
    search_extensions both called it, so the extension marketplace search had the same defect
    (measured by reading the code: they were its only two callers).
  • F8. list_resources_from_extension joined an empty resource list into "" and returned it as
    a listing. The fan-out passed that through, and so did the named branch.

What changed

F5: marketplace/search.rs, shared by both marketplace searches

  • Tokenised query. The query is split into terms at whitespace and punctuation, lower-cased and
    de-duplicated. r-scripting becomes r + scripting, and ggplot2 stays one word.
  • Union, ranked. An entry matching ANY term is a hit. Hits are ranked by:
    1. a verbatim match of the whole query — what the old matcher found, so nothing it returned is lost;
    2. then the number of terms matched, so entries matching every term come first;
    3. then where each term matched (id/name > tags, keywords, category, organization > description)
      and how exactly (the whole word > its start > inside it);
    4. then registry order.
  • Two precision rules, both needed by the measured query itself:
    • A term under three characters matches whole words only, so r finds the R language rather than
      every word containing an r.
    • Filler words (a, about, for, or, … plus skill(s) / extension(s) for their own
      catalog) are dropped, unless nothing else is left. In a union they would otherwise inflate the
      term count of every entry whose prose uses them. A plural also falls back to its singular
      (heatmapsheatmap).
  • Both tools now report the match. They return the terms the query was read as, plus each
    hit's matchedTerms. A model reading a long list can then tell an all-term hit from one that
    matched a single common word.
  • A query that still matches nothing returns a guidance sentence. For skills it says how many
    skills the registry holds; for extensions, how many extensions are available to this model. It also
    suggests one shorter term, or browsing with no query.
    • ⚠ The extension count is browse_extensions(caller) — what the caller may see — never the
      registry's size, which for a public model would count the private rows. A test asserts that a
      public caller missing on a hidden private row reads byte-for-byte like a miss on nothing, so
      the guidance cannot become the private catalog's oracle.
  • The two tool descriptions and the skills system-prompt sentence now say "ranks", not "filters".
    The payload assembly moved into marketplace_skill_page_json / marketplace_extensions_json so
    it is testable without the network.

F8: an empty list_resources is a sentence

  • Fan-out, nothing found, from Gate E's roster. Example, measured at runtime below:
    No resources to list. `computercontroller` supports resources and was asked, but is not publishing any right now. The other 11 extensions this chat can reach do not offer resources.
    The sentence also names extensions that support resources but failed to list, and ones refused to
    this model by a cross-affiliation mismatch (DR-26 lists and marks those rather than hiding them).
    Without that group, the count would describe them as extensions that "do not offer resources".
  • Named extension with nothing to list: `developer` has no resources to list: it supports resources but is not publishing any right now.
  • The privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18) #219 rule holds. Every name is filtered through allowed_extension_keys(admitted)
    Gate E's roster, the same verdict privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18) #219 used for the not-found message in read_resource_tool.
    A refused private extension is never named and never counted. admitted is threaded, never
    resampled. For a named private extension, a public caller is still refused, in the same words
    a name that is not installed gets (M18). It is never told "has no resources", which would confirm
    the extension is installed.
  • list_resources_from_extension now returns nothing for an empty list, not "". The
    empty-listing roster lives in unlisted_resources, which keeps list_resources under the
    too_many_lines baseline.

Tests

Fail-before was measured. I reverted only the production hunks, not the tests: F5's term loop
was disabled (leaving exactly the old verbatim-substring behaviour), and F8's empty-list handling was
put back. Then the new tests ran against that tree:

marketplace::tests::a_natural_language_skill_query_returns_the_union_ranked                     FAILED
  left: []
 right: ["ggplot-visualization", "r-scripting", "data-visualization", "python-scripting", "clinical-biostatistics"]
agents::extension_manager::tests::an_empty_resource_listing_says_who_was_asked_naming_only_gate_es_roster FAILED
  left: ""
agents::extension_manager::tests::a_named_extension_with_nothing_to_list_says_so                FAILED
  left: ""
agents::extension_manager::tests::an_empty_listing_names_a_cross_affiliation_refusal_as_withheld FAILED
  left: ""
test result: FAILED. 11 passed; 11 failed

New tests:

  • Matcher, against the measured queries (marketplace.rs). Seven registry rows are copied
    verbatim from landing/registry.json at 7c96d79 and frozen, so the exact ranking cannot drift:
    • the phrase returns the five hits above, in that order;
    • ggplot returns exactly the two QA measured;
    • r-scripting ranks r-scripting first;
    • the empty query still browses everything;
    • scientific-visual-communication (visual, not visualization) is correctly not a hit.
  • The same property on the shipped registry. Shape only, so a new skill cannot break it: the
    phrase finds every skill each of its words finds alone.
  • Matcher unit tests (marketplace/search.rs): tokenising, filler, whole-word short terms,
    in-word and plural matching, union ranking, verbatim hits, browse.
  • Extension search: the caller filter runs before the ranking (public 1 row, private 2 rows,
    best first).
  • Tool payloads: terms and matchedTerms present, guidance on zero hits with the registry
    size, browsing unchanged. For extensions, the count is visible-only and a hidden-row miss reads
    like a miss on nothing.
  • F8, public and private columns, as in privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18) #219's M6 test. The public sentence names developer
    and not ucsfomopagent, and the private server's contact counter stays at 0. The private sentence
    names both.
  • F8 named branch, for a public and a private caller, plus the public refusal staying
    indistinguishable from an absent name.
  • F8 cross-affiliation withheld group, driven end to end with a Stanford-affiliated private
    model.
  • The composer's remaining branches: nothing reachable, and ones that failed to list.

Results (BIOROUTER_DISABLE_KEYRING=true on every run):

cargo test -p biorouter --lib -- skill extension_manager marketplace   337 passed; 0 failed; 1 ignored
  (selects agents::extension_manager 95, agents::skills_extension 68, agents::extension_manager_extension 57,
   agents::skill_package 53, agents::skill_catalog 16, marketplace::tests 10, agents::session_skills 10,
   marketplace::search 7, + 22 across 11 other modules whose test names match a filter)
cargo test -p biorouter-server --lib -- routes::skills                  4 passed; 0 failed
cargo test -p biorouter --lib                                           3810 passed; 0 failed; 2 ignored
cargo test -p biorouter --test privacy_toggle                           4 passed; 0 failed
cargo test -p biorouter --test privacy_guard_wiring                     3 passed; 0 failed
cargo test -p biorouter --test privacy_capability                       4 passed; 0 failed
cargo fmt --all -- --check                                              FMT OK
./scripts/clippy-lint.sh                                                clippy -D warnings clean; too_many_lines ok; no banned TLS crates

No OpenAPI regeneration: no route, handler signature or #[utoipa::path] changed.

Runtime verification

I ran my own sandboxed instance with ~/biorouter-runs/launch-dev-gui.sh (CDP 9391; the QA
instances on 9371-9376 were not touched). The daemon was this worktree's own seam build, confirmed
with ps -p <DAEMON_PID>, and the model was versa_azure / gpt-5.5-2026-04-24. Both prompts were
typed into the composer of one chat.

1. "Search the BAAM marketplace for a skill about R scripting or ggplot visualization"

The model called skills.searchMarketplaceSkills({ query: "R scripting ggplot visualization" })
inside execute_code: the exact query QA measured at total: 0. The raw tool result, abridged to
the envelope and each hit's id:

{"source": "live", "stale": false, "cacheWarning": null, "total": 11, "offset": 0, "limit": 20,
 "returned": 11, "nextOffset": null, "terms": ["r", "scripting", "ggplot", "visualization"], "skills": [
  ggplot-visualization           matchedTerms=["r", "ggplot", "visualization"]
  r-scripting                    matchedTerms=["r", "scripting"]
  data-visualization             matchedTerms=["ggplot", "visualization"]
  empirical-paper-submission-rr  matchedTerms=["r"]
  python-scripting               matchedTerms=["scripting"]
  clinical-biostatistics         matchedTerms=["r"]
  econometrics-toolkit           matchedTerms=["r"]
  code-simplifier                matchedTerms=["r"]
  copy-number                    matchedTerms=["visualization"]
  reporting                      matchedTerms=["r"]
  stata-research-workbench       matchedTerms=["r"] ]}

The model answered "Found 11 BAAM marketplace skill matches. The most relevant are: 1. ggplot2
Visualization … 2. R Scripting … 3. Data Visualization …"
.

2. "List the resources the extensions offer"

The model called extensionmanager.list_resources({}). The raw tool result:

"No resources to list. `computercontroller` supports resources and was asked, but is not publishing any right now. The other 11 extensions this chat can reach do not offer resources."

The model answered: "No extension resources are currently available. computercontroller supports
resources, but it is not publishing any right now. The other reachable extensions/capabilities in this
chat do not offer resources."

This runtime model is private. The public-caller sentence is covered by the unit tests above, which
bind a public capability; a public runtime model is ruled out by the test-model rule
(versa_azure only).

Not done here (same defect, different code)

  • The desktop BAAM "Browse skills" modal (ui/desktop/src/components/baam/registry.ts,
    skillMatches / extensionMatches) is a separate TypeScript copy of the old whole-phrase
    substring match. A user typing R scripting ggplot into the search box still gets nothing.
  • The installed-skill search (skills__searchSkills, handle_search_skills) ANDs its
    whitespace terms. The same phrase finds an installed skill only if one skill contains all four
    words.

Neither shares this matcher, and QA measured neither, so both are left for a follow-up.

🤖 Generated with Claude Code

…mpty result (F5)

The 2026-09-10 composer QA run measured `skills__searchMarketplaceSkills
{query: "R scripting ggplot visualization"}` -> `total: 0` against a live
registry where `ggplot` alone found 2 and `r-scripting` found 1. The matcher
asked whether the WHOLE lowercased query was a substring of one field, so any
phrase failed, and the model told the user the marketplace had nothing.

Both marketplace searches (skills and extensions) shared that matcher, so both
move onto `marketplace/search.rs`: the query is split into terms at whitespace
and punctuation, an entry matching ANY term is a hit, and hits are ranked by a
verbatim match (what the old matcher found, so nothing is lost), then by how
many terms matched, then by where (id/name > labels > description). Terms under
three characters match whole words only, so `r` finds the R language rather
than every word containing an r, and filler words are dropped.

Each hit now carries `matchedTerms`, the result carries the `terms` the query
was read as, and a query that still matches nothing returns `guidance` saying
how many entries the registry holds and to try shorter terms. The extension
count is what the caller may see, never the registry's size, so a public model
is never told how many private rows exist.
…ot "" (F8)

`extensionmanager__list_resources` answered `""` when no extension had a
resource, so the model could only report that the tool "returned an empty
string" — every other empty or refusing result explains itself.

An empty listing now says which extensions were asked and that none is
publishing anything, how many reachable extensions do not offer resources, and
which could not be listed or were refused to this model (a cross-affiliation
mismatch). Naming a named extension says it has none.

Every name is filtered through Gate E's roster (`allowed_extension_keys`), the
rule #219 set for the not-found message next door: a public caller is never
named, or counted, a private extension the fan-out declined to reach.
…own method

list_resources had grown to 105 lines, over the too_many_lines baseline. The
Gate E filtering moves into unlisted_resources unchanged.
@Broccolito
Broccolito merged commit 7e21095 into main Sep 11, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/nervous-cohen-475763 branch September 11, 2026 20:49
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