Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions dashboard/backend/domain/agents/marketplace.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Open agent templates for the Agent Supermarket.

Templates are defined in ``dashboard/config/marketplace.json`` so baseline open
agents can be added without schema migrations. The listing is public; cloning
creates a user-owned built-in agent with the template's pipeline copied in.
Templates are defined in ``dashboard/config/marketplace.json`` so competition
models and hosted agents can be added without schema migrations. The listing
is public; cloning creates a user-owned built-in agent with the template's
pipeline copied in.
"""

from __future__ import annotations
Expand All @@ -11,14 +12,29 @@
from functools import lru_cache
from typing import Any, Dict, List, Optional

from dashboard.backend.domain.agents.taxonomy import (
category_sort_rank,
normalize_category,
)
from dashboard.backend.domain.agents.taxonomy import normalize_category
from dashboard.backend.paths import CONFIG_DIR

_MARKETPLACE_PATH = CONFIG_DIR / "marketplace.json"

# Community supermarket rows. Declared order is display order: LLMs first,
# then Open Agents. Unknown / omitted values fall through ``_normalize_shelf``.
MARKETPLACE_SHELVES = ("llms", "open")


def _normalize_shelf(raw: Dict[str, Any]) -> str:
"""Return ``llms`` or ``open``.

Explicit ``shelf`` on the catalog row wins. Otherwise a non-pipeline
runtime (today: AI Hedge Fund) is an open agent, so a future hosted
project does not have to remember the field to land on the right row.
"""
explicit = str(raw.get("shelf") or "").strip().lower()
if explicit in MARKETPLACE_SHELVES:
return explicit
runtime_type = str(raw.get("runtime_type") or "pipeline")
return "open" if runtime_type != "pipeline" else "llms"


def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]:
# This "category" and an agent's "category" used to be two different
Expand Down Expand Up @@ -47,6 +63,8 @@ def _public_template(raw: Dict[str, Any]) -> Dict[str, Any]:
"author": raw.get("author") or "Community",
"runtime_type": runtime_type,
"step_count": step_count,
"shelf": _normalize_shelf(raw),
"card_subtitle": str(raw.get("card_subtitle") or "").strip() or None,
"mode": (
"runtime"
if runtime_type != "pipeline"
Expand Down Expand Up @@ -82,18 +100,17 @@ def _load_catalog() -> Dict[str, Dict[str, Any]]:


def list_marketplace_templates() -> List[Dict[str, Any]]:
"""Return public marketplace cards grouped by shelf, then sorted by name.
"""Return public marketplace cards grouped by supermarket shelf.

Ordered by ``category_sort_rank`` rather than by the slug itself: the slugs
are not alphabetical in shelf order, so a plain ``sorted`` on the raw value
leads the Community listing with the A-share shelf. Uncategorized templates
sort last.
``MARKETPLACE_SHELVES`` declaration order is the page order (LLMs, then
Open Agents). Within a shelf the catalog's insertion order is preserved
(stable sort) so Community can list models in the leaderboard roster
order without a second sort key.
"""
items = [_public_template(raw) for raw in _load_catalog().values()]
return sorted(
items,
key=lambda t: (category_sort_rank(t.get("category")), str(t.get("name") or "")),
)
# _public_template always sets "shelf" via _normalize_shelf, which only
# returns members of MARKETPLACE_SHELVES -- no fallback branch needed.
return sorted(items, key=lambda t: MARKETPLACE_SHELVES.index(t["shelf"]))


def get_marketplace_template(template_id: str) -> Optional[Dict[str, Any]]:
Expand Down
4 changes: 2 additions & 2 deletions dashboard/backend/tests/test_admin_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert "window.AdminAnalytics.syncAuth(user)" in APP_JS
assert "window.AdminAnalytics.onEnter()" in APP_JS
assert "window.AdminAnalytics.refresh()" in APP_JS
assert 'styles.css?v=130' in APP_HTML
assert 'app.js?v=124' in APP_HTML
assert 'styles.css?v=131' in APP_HTML
assert 'app.js?v=126' in APP_HTML
assert 'js/admin-analytics.js?v=2' in APP_HTML
assert 'js/admin-tabs.js?v=3' in APP_HTML

Expand Down
26 changes: 23 additions & 3 deletions dashboard/backend/tests/test_agent_starter_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,30 @@ def test_create_still_returns_the_one_time_api_key(client):
assert body["session_id"]


def test_marketplace_clone_keeps_its_own_pipeline(client):
"""A template with its own pipeline must not be overwritten by the seed."""
def test_marketplace_clone_keeps_its_own_pipeline(client, monkeypatch):
"""A template with its own pipeline must not be overwritten by the seed.

The shipped catalog is now one-step competition-model cards plus a hosted
runtime, so the three-step shape is faked — the guard is the copy, not
which template still ships it.
"""
fake_template = {
"template_id": "three-step-template",
"name": "Three-Step Template",
"model_name": "anthropic/claude-haiku-4-5",
"pipeline": [
{"id": "sub_gather", "presetKey": "info_gather", "prompt": "gather"},
{"id": "sub_signal", "presetKey": "info_to_signal", "prompt": "signal"},
{"id": "sub_exec", "presetKey": "signal_to_execution", "prompt": "exec"},
],
}
monkeypatch.setattr(
marketplace_module,
"get_marketplace_template",
lambda template_id: fake_template if template_id == "three-step-template" else None,
)
cloned = client.post(
"/api/v1/agents/marketplace/pipeline-analyst/clone",
"/api/v1/agents/marketplace/three-step-template/clone",
json={},
headers={"X-Session-Id": str(uuid.uuid4())},
)
Expand Down
56 changes: 23 additions & 33 deletions dashboard/backend/tests/test_agents_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ def test_marketplace_listing_and_clone(client):
assert listing.status_code == 200
templates = listing.json()["templates"]
assert templates
assert any(t["template_id"] == "balanced-starter" for t in templates)
assert any(t["template_id"] == "claude-haiku-4-5" for t in templates)
hedge_fund_card = next(
t for t in templates if t["template_id"] == "ai-hedge-fund"
)
Expand All @@ -751,13 +751,13 @@ def test_marketplace_listing_and_clone(client):
browser_session = str(uuid.uuid4())
headers = {"X-Session-Id": browser_session}
cloned = client.post(
"/api/v1/agents/marketplace/balanced-starter/clone",
"/api/v1/agents/marketplace/claude-haiku-4-5/clone",
json={},
headers=headers,
)
assert cloned.status_code == 200
agent = cloned.json()["agent"]
assert agent["name"] == "Balanced Starter"
assert agent["name"] == "Claude Haiku 4.5"
assert agent["agent_type"] == "builtin"
assert agent.get("pipeline")
assert agent["pipeline"][0]["presetKey"] == "simple_instruction"
Expand Down Expand Up @@ -825,43 +825,33 @@ def test_marketplace_catalog_shape():
)

# "Pipeline" is banned product-copy vocabulary (glossary: pipeline ->
# "multi-step strategy"); the template_id stays "pipeline-analyst" since
# it's an API identifier baked into clone URLs, but the display name --
# the card's largest text -- must not carry the word.
# "multi-step strategy"). The hosted card and the competition-model cards
# must not put that word in the display name -- the card's largest text.
names = {t["template_id"]: t["name"] for t in templates}
assert names["pipeline-analyst"] == "Three-Step Analyst"
assert "Pipeline Analyst" not in names.values()
assert names["ai-hedge-fund"] == "AI Hedge Fund"
assert "Pipeline" not in " ".join(names.values())


def test_marketplace_listing_is_ordered_by_shelf_not_by_slug():
"""Community cards group by market in *declaration* order, not slug order.

The recategorization onto slugs quietly changed which card leads the page:
``sorted`` on the raw value orders cn_ashares < us_stocks, so the A-share
template became card #1 on a U.S.-focused product. Nothing caught it because
no test asserted order. ``category_sort_rank`` keys on the AgentCategory
Literal's declaration order instead, which is also the order MARKET_LABELS
renders the market chips in, so the two surfaces agree.
"""Community cards group by supermarket shelf, LLMs then Open Agents.

Within a shelf the catalog's insertion order is preserved so the LLM
row can follow the leaderboard roster instead of alphabetical names.
"""
import dashboard.backend.domain.agents.marketplace as marketplace_mod
from dashboard.backend.domain.agents.taxonomy import (
AGENT_CATEGORY_ORDER,
category_sort_rank,
)

marketplace_mod.reload_marketplace_catalog()
templates = marketplace_mod.list_marketplace_templates()

ranks = [category_sort_rank(t.get("category")) for t in templates]
assert ranks == sorted(ranks), "templates are not grouped in shelf order"

# The U.S. market leads; uncategorized templates never do.
assert templates[0]["category"] == AGENT_CATEGORY_ORDER[0] == "us_stocks"
assert templates[-1]["category"] == "cn_ashares"

# Within a shelf, still by name.
us_stocks = [t["name"] for t in templates if t["category"] == "us_stocks"]
assert us_stocks == sorted(us_stocks)
shelves = [t.get("shelf") for t in templates]
assert set(shelves) <= set(marketplace_mod.MARKETPLACE_SHELVES)
llms = [t for t in templates if t["shelf"] == "llms"]
opens = [t for t in templates if t["shelf"] == "open"]
assert llms, "the LLM shelf is empty"
assert opens, "the Open Agents shelf is empty"
assert templates[0]["shelf"] == "llms"
assert templates[-1]["shelf"] == "open"
assert [t["name"] for t in opens] == ["AI Hedge Fund"]


def test_uncategorized_templates_sort_last_and_carry_no_fake_shelf():
Expand Down Expand Up @@ -1416,7 +1406,7 @@ def test_builtin_listing_echoes_category(client):
def test_clone_honours_a_model_name_override(client):
"""Community's "Choose model" affordance clones a template onto another model."""
cloned = client.post(
"/api/v1/agents/marketplace/balanced-starter/clone",
"/api/v1/agents/marketplace/claude-haiku-4-5/clone",
json={"model_name": "deepseek/deepseek-v4-pro"},
headers={"X-Session-Id": str(uuid.uuid4())},
)
Expand All @@ -1429,7 +1419,7 @@ def test_clone_falls_back_to_the_template_model(client, blank):
"""Omitted or blank means "use the template's model", not "use empty"."""
body = {} if blank is None else {"model_name": blank}
cloned = client.post(
"/api/v1/agents/marketplace/balanced-starter/clone",
"/api/v1/agents/marketplace/claude-haiku-4-5/clone",
json=body,
headers={"X-Session-Id": str(uuid.uuid4())},
)
Expand All @@ -1441,7 +1431,7 @@ def test_clone_does_not_validate_the_model_name(client):
"""No whitelist here: POST /agents and PATCH /agents/{id} don't have one either,
and a Literal would drag in the openapi enum deploy gate #313 discharged."""
cloned = client.post(
"/api/v1/agents/marketplace/balanced-starter/clone",
"/api/v1/agents/marketplace/claude-haiku-4-5/clone",
json={"model_name": "some/unreleased-model"},
headers={"X-Session-Id": str(uuid.uuid4())},
)
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_analytics_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _function_body(source: str, signature: str) -> str:


def test_analytics_script_loads_between_app_and_page_scripts():
app_at = APP_HTML.index('<script src="app.js?v=124" defer></script>')
app_at = APP_HTML.index('<script src="app.js?v=126" defer></script>')
analytics_at = APP_HTML.index(
'<script src="js/analytics.js?v=1" defer></script>'
)
Expand Down
8 changes: 4 additions & 4 deletions dashboard/backend/tests/test_app_copy_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,12 +403,12 @@ def test_marketplace_mode_chip_labels_avoid_banned_words():
`renderMarketplaceGrid` so this doesn't over-match an unrelated string
elsewhere in app.js.
"""
body = fn_body("function renderMarketplaceGrid")
body = fn_body("function buildMarketplaceCardHtml")
assert "'Hosted runtime'" not in body
assert "'Multi-step pipeline'" not in body
assert "'Hosted'" in body
assert "'Multi-step strategy'" in body
assert "'Simple instruction'" in body
assert "Open Source" in body
assert "'Simple instruction'" not in body
assert "'Multi-step strategy'" not in body


def test_each_agent_gets_its_own_trading_session_is_gone():
Expand Down
4 changes: 2 additions & 2 deletions dashboard/backend/tests/test_backtest_comparison_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ def test_exact_raw_ties_mark_every_tied_series_best():

def test_comparison_script_and_semantic_table_ship_before_app():
helper = '<script src="js/backtest-comparison.js?v=1" defer></script>'
app = '<script src="app.js?v=124" defer></script>'
assert 'href="styles.css?v=130"' in APP_HTML
app = '<script src="app.js?v=126" defer></script>'
assert 'href="styles.css?v=131"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
"performanceLegend",
Expand Down
4 changes: 2 additions & 2 deletions dashboard/backend/tests/test_frontend_fast_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ def test_cache_busters_bumped():
# the next bump, so the exact one looks like the broken guard and gets
# "fixed" by loosening it. That collision has already cost this repo one
# round of follow-ups (#347/#348).
assert "app.js?v=124" in APP_HTML
assert "app.js?v=126" in APP_HTML
assert "js/agent-editor.js?v=30" in APP_HTML
assert "styles.css?v=130" in APP_HTML
assert "styles.css?v=131" in APP_HTML
assert "js/leaderboard.js?v=32" in APP_HTML
assert "home-page.js?v=50" in APP_HTML
assert "js/credit-format.js?v=1" in APP_HTML
Expand Down
Loading
Loading