diff --git a/.github/published-images.json b/.github/published-images.json index d460e1f33..b877845ca 100644 --- a/.github/published-images.json +++ b/.github/published-images.json @@ -15,5 +15,6 @@ "agent-strands", "agent-ag2", "agent-langroid", - "agent-mastra" + "agent-mastra", + "agent-antigravity" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d33656e0..81a00acce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,13 @@ jobs: . .venv-adk/bin/activate python -m pip install --requirement agent-adk/requirements.txt --requirement agent-adk/requirements-test.txt python -m pytest agent-adk/tests -q + - name: Antigravity provider pin and token gate + run: | + set -euo pipefail + python -m venv .venv-antigravity + . .venv-antigravity/bin/activate + python -m pip install --requirement agent-antigravity/requirements.txt --requirement agent-antigravity/requirements-test.txt + python -m pytest agent-antigravity/tests -q - name: Langroid model-choice and tool-call regressions run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index e6f9f1c76..4a7ca18ab 100644 --- a/.gitignore +++ b/.gitignore @@ -41,10 +41,13 @@ charts/*/charts/ # The environments are here for the same reason and are the more expensive mistake: `.venv` is # hundreds of megabytes of platform-specific binaries that would be useless to anybody who cloned # them. `uv` makes `.venv` and the older tooling makes `venv`, and both spellings are in use across -# these packages. +# these packages. CI gives each Python Bot an environment named after it — `.venv-adk`, +# `.venv-antigravity` — and running those same commands locally leaves them in the tree, so the +# suffixed spelling is ignored too. __pycache__/ *.py[cod] .venv/ +.venv-*/ venv/ *.egg-info/ .pytest_cache/ diff --git a/agent-adk/requirements.txt b/agent-adk/requirements.txt index 0841b663d..8b5befab0 100644 --- a/agent-adk/requirements.txt +++ b/agent-adk/requirements.txt @@ -1,6 +1,12 @@ ag-ui-adk google-adk -litellm +# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports +# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather +# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from +# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'` +# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops +# requiring mcp, or declares it. +litellm<1.106.0 fastapi python-multipart uvicorn[standard] diff --git a/agent-agno/requirements.txt b/agent-agno/requirements.txt index b33b313ba..af537af60 100644 --- a/agent-agno/requirements.txt +++ b/agent-agno/requirements.txt @@ -1,5 +1,11 @@ agno[agui] -litellm +# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports +# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather +# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from +# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'` +# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops +# requiring mcp, or declares it. +litellm<1.106.0 fastapi python-multipart uvicorn[standard] diff --git a/agent-antigravity/Dockerfile b/agent-antigravity/Dockerfile new file mode 100644 index 000000000..cd96befd7 --- /dev/null +++ b/agent-antigravity/Dockerfile @@ -0,0 +1,23 @@ +# Google Antigravity, as a Bot. Python rather than Bun because that is what the integration is +# published in, and the protocol is the only thing a Bot has to share with the others. +# +# `slim` rather than `alpine`: the SDK's wheel bundles a Go `localharness` binary built against +# glibc (manylinux_2_17), and musl cannot load it. +FROM python:3.12-slim + +WORKDIR /app + +# Dependencies before source, so editing the Bot does not re-resolve the framework. +COPY agent-antigravity/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY agent-antigravity/src ./src +COPY shared/model_providers.py shared/model-providers.json /shared/ + +ENV PORT=4214 +EXPOSE 4214 +# 0.0.0.0, not `::`. uvicorn binds `::` as IPv6-only, with no v4-mapped addresses, so a container +# started that way refuses 127.0.0.1: the Compose healthcheck never passes and the server cannot +# reach the Bot. Measured, not assumed. Inside a container this is the container's own namespace, +# and Compose is what decides which host addresses it is published on. +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "4214"] diff --git a/agent-antigravity/requirements-test.txt b/agent-antigravity/requirements-test.txt new file mode 100644 index 000000000..a91d60c68 --- /dev/null +++ b/agent-antigravity/requirements-test.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +pytest==9.0.2 diff --git a/agent-antigravity/requirements.txt b/agent-antigravity/requirements.txt new file mode 100644 index 000000000..5430420f2 --- /dev/null +++ b/agent-antigravity/requirements.txt @@ -0,0 +1,3 @@ +ag-ui-antigravity +fastapi +uvicorn[standard] diff --git a/agent-antigravity/src/main.py b/agent-antigravity/src/main.py new file mode 100644 index 000000000..690242b1d --- /dev/null +++ b/agent-antigravity/src/main.py @@ -0,0 +1,108 @@ +"""Google Antigravity as a Bot, through `ag_ui_antigravity`, which AG-UI maintains. + +GEMINI-ONLY, AND PINNED. Every other Bot in the box takes whatever provider the deployment chose: +they reach their model through LiteLLM or an equivalent, so `BOT_PROVIDER` moving them to Anthropic +or a compatible endpoint is a setting, not a break. Antigravity is not like that. Its SDK drives a +bundled Go `localharness`, and that harness wants a Gemini key wherever its base url points — the +integration's own README says so. So the provider is pinned here rather than read from the +environment, and the catalogue row says `credential: "google"` so the picker asks for the right key +instead of offering this row to a deployment that holds only an OpenAI one. + +The model is still the deployment's to choose. `BOT_MODEL` names which Gemini; the pin only stops +the *provider* drifting to one the harness cannot use. + +WHERE the Gemini lives is the deployment's too. The `google` row in `shared/model-providers.json` +names both variables this Bot reads — the key and the base url — and they are read from that row +rather than spelled out here, so renaming either is one edit to the file every language reads. A +base url set there sends the harness at a Gemini-compatible endpoint instead of Google's: a +gateway, a proxy that holds the real key, or a mock while someone tries the Bot out without one. +That url is the API ROOT, with no version path on it — `http://host:4141`, not +`http://host:4141/v1beta`. The harness appends `/v1beta/models/:streamGenerateContent` +itself, and a url carrying the version already gets it twice and 404s, which the harness reports +as the unplaceable `agent executor error: Error 0, Message: Not found`. Measured against a logging +proxy, because that message names neither the url nor the status. +""" + +import hmac +import os +import sys +from pathlib import Path + +# The spec file every language in the box reads: one level above this Bot in the repository, and +# one level above /app/src in the image the Dockerfile builds. +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import SPEC, bot_settings + +from ag_ui_antigravity import AntigravityAgent, create_antigravity_app +from fastapi import Request +from fastapi.responses import JSONResponse +from google.antigravity.models import GeminiAPIEndpoint + +TOKEN_HEADER = "x-openbot-agent-token" + +#: The Gemini row this Bot is pinned to. Both variable names come from it. +_GOOGLE = SPEC["providers"]["google"] + + +def _model() -> str: + """Which Gemini this deployment wants. + + `pinned_provider` beats `BOT_PROVIDER`, which is the point: see the module docstring. The + returned model is the bare name, not `provider/model` — Antigravity takes the Gemini model id + directly rather than through a LiteLLM-style prefix. + """ + return bot_settings("agent-antigravity", pinned_provider="google").model + + +def _api_key() -> str | None: + """The key, under the name the provider row gives it. + + `GEMINI_API_KEY` is the SDK's own spelling and is taken as a second name for the same thing, + because a deployment that already set it for another Google tool should not have to set it + twice. Blank is not a choice: compose hands an unset variable through as an empty string. + """ + for variable in (_GOOGLE["key_variable"], "GEMINI_API_KEY"): + value = (os.environ.get(variable) or "").strip() + if value: + return value + return None + + +def _agent() -> AntigravityAgent: + """The harness, pointed at whatever Gemini this deployment runs. + + With a base url the endpoint is spelled out: `AntigravityAgent(base_url=...)` is a DIFFERENT + and mutually exclusive path, meant for unauthenticated local servers, and it drops the key. + `endpoint=GeminiAPIEndpoint(...)` is the one that carries both — which is what a gateway, or a + mock in front of the Gemini API, needs. + """ + model, api_key = _model(), _api_key() + base_url = (os.environ.get(_GOOGLE["base_url_variable"]) or "").strip() + if base_url: + return AntigravityAgent( + model=model, + endpoint=GeminiAPIEndpoint(base_url=base_url, api_key=api_key), + ) + # Named rather than left to the SDK's own environment read, so a missing key is this Bot's + # failure at start-up rather than a stream that opens and dies. + return AntigravityAgent(model=model, api_key=api_key) + + +app = create_antigravity_app(_agent(), path="/") + + +@app.middleware("http") +async def refuse_without_the_server_token(request: Request, call_next): + if request.url.path != "/health": + expected = (os.environ.get("MANAGED_AGENT_TOKEN") or "").strip() + offered = (request.headers.get(TOKEN_HEADER) or "").strip() + if not expected or not hmac.compare_digest( + offered.encode("utf-8"), expected.encode("utf-8") + ): + return JSONResponse({"error": "unauthorised"}, status_code=401) + return await call_next(request) + + +@app.get("/health") +async def health(): + return {"ok": True, "harness": "antigravity"} diff --git a/agent-antigravity/tests/test_main.py b/agent-antigravity/tests/test_main.py new file mode 100644 index 000000000..7d144f150 --- /dev/null +++ b/agent-antigravity/tests/test_main.py @@ -0,0 +1,122 @@ +"""The app this Bot actually serves: it builds, it guards, and health stays open. + +Importing `src.main` is most of the point. It constructs a real `AntigravityAgent` and a real +FastAPI app at module scope, so a wrong keyword, a renamed export or a missing dependency fails +here rather than at the first run against a live deployment. +""" + +import importlib +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +TOKEN = "test-managed-token" +TOKEN_HEADER = "x-openbot-agent-token" + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key") + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + import src.main + + importlib.reload(src.main) + return TestClient(src.main.app) + + +def test_health_needs_no_token(client): + """Compose polls this before anything holds a token.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "harness": "antigravity"} + + +def test_a_run_without_the_server_token_is_refused(client): + response = client.post("/", json={}) + assert response.status_code == 401 + + +def test_a_run_with_the_wrong_token_is_refused(client): + response = client.post("/", json={}, headers={TOKEN_HEADER: "wrong"}) + assert response.status_code == 401 + + +def test_the_token_gate_is_not_a_prefix_match(client): + """`hmac.compare_digest` on the whole value, not a startswith.""" + response = client.post("/", json={}, headers={TOKEN_HEADER: TOKEN + "extra"}) + assert response.status_code == 401 + + +def test_the_agent_is_built_on_the_pinned_provider(monkeypatch): + """Even told to use OpenAI, this Bot asks Antigravity for a Gemini.""" + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key") + monkeypatch.setenv("BOT_PROVIDER", "openai") + monkeypatch.delenv("BOT_MODEL", raising=False) + import src.main + + importlib.reload(src.main) + assert src.main._model() == "gemini-3.6-flash" + + +def _rebuilt(): + import src.main + + importlib.reload(src.main) + return src.main + + +def test_a_base_url_becomes_a_gemini_endpoint(monkeypatch): + """The variable the `google` provider row names points the harness somewhere else. + + Reading `_endpoint` is reaching into the SDK, and that is deliberate: the alternative is a test + that only greps this Bot's source, which would pass just as happily with the wrong keyword. + `AntigravityAgent(base_url=...)` is the wrong keyword — it is a separate path for + unauthenticated local servers and it carries no key — so this asserts the key came along. + """ + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key") + monkeypatch.setenv("GOOGLE_GENERATIVE_AI_BASE_URL", "http://aimock:4141") + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + agent = _rebuilt()._agent() + assert agent._endpoint.base_url == "http://aimock:4141" + assert agent._endpoint.api_key == "not-a-real-key" + + +def test_without_a_base_url_the_key_goes_straight_to_google(monkeypatch): + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key") + monkeypatch.delenv("GOOGLE_GENERATIVE_AI_BASE_URL", raising=False) + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + agent = _rebuilt()._agent() + assert agent._endpoint is None + assert agent._api_key == "not-a-real-key" + + +def test_a_blank_base_url_is_not_a_choice(monkeypatch): + """Compose hands an unset variable through as an empty string, and `GeminiAPIEndpoint("")` + would send the harness at nothing at all.""" + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.setenv("GOOGLE_API_KEY", "not-a-real-key") + monkeypatch.setenv("GOOGLE_GENERATIVE_AI_BASE_URL", " ") + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert _rebuilt()._agent()._endpoint is None + + +def test_the_sdks_own_spelling_of_the_key_is_taken_too(monkeypatch): + monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "from-the-other-name") + monkeypatch.delenv("GOOGLE_GENERATIVE_AI_BASE_URL", raising=False) + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert _rebuilt()._agent()._api_key == "from-the-other-name" diff --git a/agent-antigravity/tests/test_model_spec.py b/agent-antigravity/tests/test_model_spec.py new file mode 100644 index 000000000..0313507d5 --- /dev/null +++ b/agent-antigravity/tests/test_model_spec.py @@ -0,0 +1,62 @@ +"""The spec file this Bot reads, and the one way it differs from every other Bot's row. + +`shared/model-providers.json` is one file every language in the box reads. This is the row for +`agent-antigravity` — and unlike the rest, the provider is PINNED. Antigravity's bundled harness +requires a Gemini key wherever its base url points, so a deployment that moved its other Bots to +OpenAI must not drag this one along: it would start, accept work, and fail every call. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "shared")) +from model_providers import BotSettings, bot_settings + +BOT_ID = "agent-antigravity" +PINNED = "google" + + +def test_the_row_this_bot_runs(monkeypatch): + monkeypatch.delenv("BOT_PROVIDER", raising=False) + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID, pinned_provider=PINNED) == BotSettings( + provider="google", model="gemini-3.6-flash" + ) + + +def test_the_provider_is_pinned_against_a_deployment_that_moved_everything_else(monkeypatch): + """BOT_PROVIDER wins everywhere else. Here it must not.""" + monkeypatch.setenv("BOT_PROVIDER", "openai") + monkeypatch.delenv("BOT_MODEL", raising=False) + assert bot_settings(BOT_ID, pinned_provider=PINNED).provider == "google" + + +def test_the_model_is_still_the_deployments_to_choose(monkeypatch): + """Pinning the provider is not pinning the model: any Gemini the deployment names is fine.""" + monkeypatch.delenv("BOT_PROVIDER", raising=False) + # Deliberately NOT the file's own model: an override that names what the row already says + # would pass whether or not the environment was read at all. + monkeypatch.setenv("BOT_MODEL", "gemini-3.8-flash") + assert bot_settings(BOT_ID, pinned_provider=PINNED) == BotSettings( + provider="google", model="gemini-3.8-flash" + ) + + +def test_this_bots_source_reads_the_spec_with_the_pin(): + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert 'bot_settings("agent-antigravity", pinned_provider="google")' in source + + +def test_the_key_and_base_url_come_from_the_provider_row(): + """Not hardcoded here. + + `shared/model-providers.json` names Google's key and base-url variables, and a Bot that + spelled them out itself would drift from the file the moment either changed. The base-url + variable is what points this Bot at a Gemini-compatible endpoint — a gateway, or aimock — + instead of Google. + """ + source = (Path(__file__).resolve().parents[1] / "src" / "main.py").read_text(encoding="utf-8") + assert '"key_variable"' in source + assert '"base_url_variable"' in source + assert "GOOGLE_API_KEY" not in source + assert "GOOGLE_GENERATIVE_AI_BASE_URL" not in source diff --git a/agent-crewai/requirements.txt b/agent-crewai/requirements.txt index 57bf9f8af..f089be47b 100644 --- a/agent-crewai/requirements.txt +++ b/agent-crewai/requirements.txt @@ -1,2 +1,11 @@ ag-ui-crewai uvicorn[standard] +# litellm 1.106.0 imports `CacheableResult` from `mcp.types`, which exists only in mcp 2.x, while +# ag-ui-crewai 1.0.0 requires mcp<2. The two cannot both be satisfied, so every CrewAI run failed +# with `APIConnectionError: cannot import name 'CacheableResult' from 'mcp.types'` — a model call +# reporting a missing import, which reads as a network fault and is not one. +# +# Held here rather than raising mcp, because mcp>=2 is only reachable by letting the resolver walk +# ag-ui-crewai back to 0.3.1 and crewai to 1.6.1. 1.105.1 does not reference the symbol at all and +# leaves every other version exactly where it was. Lift this when ag-ui-crewai supports mcp 2. +litellm<1.106.0 diff --git a/agent-langroid/requirements.txt b/agent-langroid/requirements.txt index 028af3797..309b26d7b 100644 --- a/agent-langroid/requirements.txt +++ b/agent-langroid/requirements.txt @@ -3,3 +3,10 @@ langroid[litellm] fastapi python-multipart uvicorn[standard] +# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports +# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather +# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from +# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'` +# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops +# requiring mcp, or declares it. +litellm<1.106.0 diff --git a/agent-llamaindex/requirements.txt b/agent-llamaindex/requirements.txt index e7e7dac57..85e103da9 100644 --- a/agent-llamaindex/requirements.txt +++ b/agent-llamaindex/requirements.txt @@ -4,3 +4,10 @@ llama-index-llms-litellm fastapi python-multipart uvicorn[standard] +# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports +# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather +# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from +# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'` +# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops +# requiring mcp, or declares it. +litellm<1.106.0 diff --git a/agent-strands/requirements.txt b/agent-strands/requirements.txt index 91c4a449c..6075b67ba 100644 --- a/agent-strands/requirements.txt +++ b/agent-strands/requirements.txt @@ -1,6 +1,12 @@ ag-ui-strands strands-agents -litellm +# litellm 1.106.0 fails every model call in this harness: it reaches for `mcp` and reports +# whatever it finds as an OpenAIException, so the error arrives as a connection fault rather +# than the missing import it is. Seen two ways — `cannot import name 'CacheableResult' from +# 'mcp.types'` where an older mcp is pinned by another dependency, and `No module named 'mcp'` +# where there is none at all. 1.105.1 does not reach for it. Lift this when litellm stops +# requiring mcp, or declares it. +litellm<1.106.0 fastapi python-multipart uvicorn[standard] diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index 02e27967d..c39d7b10b 100644 --- a/app/src/lib/plugins/tool-name.ts +++ b/app/src/lib/plugins/tool-name.ts @@ -25,14 +25,16 @@ export function readToolName(name: string): ToolName { const parts = name.split("__"); if (parts.length < 3 || parts[0] !== "mcp") return { label: name }; - const [, server, ...rest] = parts; + const [, rawServer, ...rest] = parts; + const server = rawServer ?? ""; const tool = rest.join("__"); + const broker = brokeredApp(server); /* * Research is named for what it did, not for who did it. `web_search_exa` would read "Web search * exa", which tells the person which vendor this deployment buys search from and nothing about * their answer. Which provider is granted is an administrator's fact, on the Bot's access page. */ - const role = researchRoleOfTool(server ?? "", tool); + const role = researchRoleOfTool(server, tool); if (role) return { label: RESEARCH_ROLE_LABELS[role] }; const label = humanise(tool); @@ -59,10 +61,35 @@ export function readToolName(name: string): ToolName { * a known limit of this rule, not a bug to chase with a word list. */ const labelWords = label.toLowerCase().split(" ").map(singular); - const wordsActedOn = labelWords.slice(1); - const serverWords = wordsOf(server ?? "").map(singular); + /* + * A BROKERED APP IS SEARCHED FOR IN THE WHOLE LABEL, VERB INCLUDED. The verb exclusion above + * exists for a server whose name collides with a verb by accident, and a broker's apps cannot + * produce that collision: Composio publishes `__`, so the app always leads and + * is never the verb. Excluding the first word here would put "googlecalendar" beside a label + * that opens with the word. + */ + const wordsActedOn = broker ? labelWords : labelWords.slice(1); + const serverWords = wordsOf(broker ?? server).map(singular); const named = containsPhrase(wordsActedOn, serverWords); - return named ? { label } : { label, detail: server }; + return named ? { label } : { label, detail: broker ?? server }; +} + +/** + * The app behind a brokered server key, or null for a server this deployment dials itself. + * + * A brokered server is keyed `-` — `composio-gmail` — because the broker is how the + * credential is held and two brokers could both offer Gmail. Neither of those is the reader's + * business: they asked their Bot about their email, not about which company holds the grant. This + * is the same argument the research relabelling above makes, and the answer is the same — which + * broker a deployment buys through is an administrator's fact, on the Bot's access page. + * + * Only the prefix is removed, never the app. "Gmail" is what was acted upon and stays legible. + */ +function brokeredApp(server: string): string | null { + const prefix = "composio-"; + if (!server.startsWith(prefix)) return null; + const app = server.slice(prefix.length); + return app.length > 0 ? app : null; } /** diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts index cf5b1308c..7a9c5bbf4 100644 --- a/app/tests/tool-name.test.ts +++ b/app/tests/tool-name.test.ts @@ -68,6 +68,36 @@ describe("naming a tool call", () => { }); }); + test("a brokered app never puts the broker on screen", () => { + // Composio is how this deployment reaches Gmail, which is plumbing: the reader asked their Bot + // about their email, not about which broker holds the grant. The app also names itself in every + // tool it publishes, so "Gmail fetch emails" already says which mailbox was read. + expect(readToolName("mcp__composio-gmail__GMAIL_FETCH_EMAILS")).toEqual({ + label: "Gmail fetch emails", + }); + expect(readToolName("mcp__composio-slack__SLACK_SEND_EMAIL")).toEqual({ + label: "Slack send email", + }); + }); + + test("a brokered app the label does not name is kept, without its broker", () => { + // The app still belongs on screen when the action does not say it — but as the app, never as + // `composio-gmail`. This is what proves the prefix is stripped rather than the detail deleted. + expect(readToolName("mcp__composio-gmail__FETCH_EMAILS")).toEqual({ + label: "Fetch emails", + detail: "gmail", + }); + }); + + test("a brokered app named by the label's first word is still dropped", () => { + // Composio writes `__`, so the app leads and is never the verb. The rule that + // protects `posts`/`post_message` excludes the first word from the search, and applying it here + // would put "googlecalendar" beside a label that opens with it. + expect( + readToolName("mcp__composio-googlecalendar__GOOGLECALENDAR_CREATE_EVENT"), + ).toEqual({ label: "Googlecalendar create event" }); + }); + test("a component the app registered is left alone", () => { // These names were chosen by somebody and are already what the reader should see. expect(readToolName("showBarChart")).toEqual({ label: "showBarChart" }); diff --git a/biome.json b/biome.json index 6493b4786..8fedba4cb 100644 --- a/biome.json +++ b/biome.json @@ -6,6 +6,7 @@ "!**/dist", "!.", "!.superpowers", + "!**/.venv*", "!app/src/lib/generated/application-config.ts", "!app/src/routeTree.gen.ts", "!desktop/src-tauri/gen", diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 3493bdea5..0588d45ba 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -44,6 +44,13 @@ pub enum Credential { /// *subscription* only ever buys its own vendor's models, and this is the row where the /// subscription path exists. Anthropic, + /// Google, and only Google. + /// + /// Separate from `AnyProvider` because Antigravity's bundled harness requires a Gemini key + /// wherever its base url points — the SDK's own README is explicit about it. Offered as + /// "any provider you already have", it would install against an OpenAI-only deployment and + /// answer nothing, which is the failure `webCreate` exists to prevent on the web side. + Google, /// The person's own endpoint. Nothing is installed and no key is ours to ask for. TheirEndpoint, } @@ -187,9 +194,8 @@ pub fn catalogue() -> Vec { mark: Option, } - let file: CatalogFile = - serde_json::from_str(include_str!("../../../shared/harnesses.json")) - .expect("shared/harnesses.json is baked in at build time and must parse"); + let file: CatalogFile = serde_json::from_str(include_str!("../../../shared/harnesses.json")) + .expect("shared/harnesses.json is baked in at build time and must parse"); file.harnesses .into_iter() diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx index 7fb430b52..fd26dece4 100644 --- a/desktop/src/HarnessPicker.tsx +++ b/desktop/src/HarnessPicker.tsx @@ -9,7 +9,10 @@ export type Harness = { summary: string; image: string | null; health_path: string | null; - credential: "any-provider" | "anthropic" | "their-endpoint"; + // Mirrors `Credential` in shared/harnesses.ts, which this cannot import: the desktop app is + // outside the workspace and reads the catalogue over the Tauri boundary as JSON. Adding a + // variant there means adding it here, and the tile below is what reads it. + credential: "any-provider" | "anthropic" | "google" | "their-endpoint"; maintainer: "first-party" | "partnership" | "community"; mark: string | null; port: number | null; @@ -137,6 +140,9 @@ export function HarnessPicker({ {row.credential === "anthropic" && ( No API key needed )} + {row.credential === "google" && ( + Needs a Gemini key + )} {row.credential === "their-endpoint" && ( Uses your running agent )} diff --git a/server/src/agents/connection-test.ts b/server/src/agents/connection-test.ts index b310558db..5648f7839 100644 --- a/server/src/agents/connection-test.ts +++ b/server/src/agents/connection-test.ts @@ -46,6 +46,20 @@ async function readOpening(body: ReadableStream): Promise { if (done) break; bytes += value.byteLength; text += decoder.decode(value, { stream: true }); + /* + * ONE RECOGNISED EVENT IS THE WHOLE QUESTION, so stop on it. + * + * The cap and the end of the stream were the only two ways out, and an agentic harness + * supplies neither: it opens with `RUN_STARTED` — ninety bytes, nowhere near the cap — and + * then goes away to call a model and run tools, holding the stream open for as long as that + * takes. The read therefore waited out the whole run and was cut off by the request timeout, + * which surfaced as "The agent started answering and the connection broke" about an agent + * that had answered correctly in its first event. Measured against `agent-antigravity`. + * + * Events rather than bytes, because bytes prove nothing: a stream of SSE comments or + * heartbeats would satisfy a "something arrived" test while being no evidence of an agent. + */ + if (eventTypesFrom(text).length > 0) break; } } finally { // The agent is very likely still writing. Nothing is going to read the rest, and leaving it open diff --git a/server/src/agents/managed-endpoint.ts b/server/src/agents/managed-endpoint.ts new file mode 100644 index 000000000..f5539babd --- /dev/null +++ b/server/src/agents/managed-endpoint.ts @@ -0,0 +1,46 @@ +/** + * Which addresses this deployment runs itself, and therefore holds the token for. + * + * Two places need this answer and they must not disagree. The run path attaches the deployment + * token to an agent registered at one of these addresses; the connection test has to knock the same + * way, or it reports a refusal for a Bot that is about to work. They drifted once already — the + * test button answered 401 for the Bot in the box — so the comparison lives here rather than being + * written out twice. + */ + +/** Keep the existing pathname slash tolerance without erasing query or fragment differences. */ +export function managedEndpointIdentity( + value: string | URL, +): string | undefined { + try { + const endpoint = new URL(value); + endpoint.pathname = endpoint.pathname.replace(/\/+$/, ""); + return endpoint.toString(); + } catch { + // An invalid stored URL is not a managed endpoint; it must not abort another agent's load. + return undefined; + } +} + +/** + * Whether `candidate` is one of the addresses this deployment runs. + * + * Every such address, not just the first. Matching a single endpoint left the harness picked during + * setup without the token: registered, addressable, routed to, and answering `401 unauthorised` to + * everything. Its container is this deployment's own, started on a port this deployment chose with + * this token in its environment, so it is the same relationship the Bot in the box has. + * + * An address that cannot be parsed is nobody's, and in particular is not ours: an unreadable + * candidate must never collide with an unreadable managed endpoint and be handed the token. + */ +export function isManagedEndpoint( + candidate: string | URL, + endpoints: readonly (string | URL | undefined)[], +): boolean { + const identity = managedEndpointIdentity(candidate); + if (identity === undefined) return false; + return endpoints.some( + (endpoint) => + endpoint !== undefined && identity === managedEndpointIdentity(endpoint), + ); +} diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 1aae47c71..bd716b2de 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -7,6 +7,7 @@ import { afterExposureChange } from "../plugins/exposure-change"; import type { SharedUseStore } from "../plugins/shared-use-store"; import { testAgentConnection } from "./connection-test"; import { checkAgentEndpoint } from "./endpoint"; +import { isManagedEndpoint } from "./managed-endpoint"; import { canManageAgent } from "./profile-policy"; import { AgentAssignedError, @@ -296,6 +297,18 @@ export function createAgentRoutes( * already reports nothing rather than reach for a store that is not there. */ sharedUse?: SharedUseStore, + /** + * The deployment's own token and the addresses it belongs to, so a connection test against one of + * them knocks the way a real run will. + * + * A named object rather than two more positional strings, for the reason the handoff parameter + * gives: everything above it is optional, and a misplaced string typechecks. + * + * `endpoints` is every address this deployment runs — the Bot in the box and any harness picked + * during setup — because the run path had to learn that same lesson: matching only the first left + * the picked harness answering 401 to everything. + */ + managedRun?: { token: string; endpoints: readonly (string | URL)[] }, ) { /** The dto with the one fact only this closure knows: whether the coworker runs on our own Bot. */ const dto = (actor: AgentActor, agent: AgentProfile) => ({ @@ -428,9 +441,29 @@ export function createAgentRoutes( if (!parsed.ok) { return context.json({ error: parsed.error }, 400); } - const headers = parsed.value; + /* + * An address this deployment runs is knocked on with this deployment's token. + * + * Nobody types that token — the run path attaches it by recognising the address — so a test + * that left it off reported "The agent refused this request. If it needs a key, add it as a + * header." for the Bot in the box and for the harness picked during setup. Both work; the + * advice was to break them. + * + * The person's own headers are applied over these rather than under, because a header they + * typed is a thing they can see and change, and this one is not. + */ + const ours = + managedRun && + typeof body?.endpoint === "string" && + isManagedEndpoint(body.endpoint, managedRun.endpoints); + const headers = { + ...(ours ? { "x-openbot-agent-token": managedRun.token } : {}), + ...parsed.value, + }; const result = await testAgentConnection(body?.endpoint, { - headers, + // Absent rather than empty when there is nothing to send, so a deployment with no managed Bot + // probes exactly as it did before this. + ...(Object.keys(headers).length > 0 ? { headers } : {}), allowPrivateHosts, allowedHosts, }); diff --git a/server/src/agents/runtime-agents.ts b/server/src/agents/runtime-agents.ts index 6e12e7ac5..6831d81ac 100644 --- a/server/src/agents/runtime-agents.ts +++ b/server/src/agents/runtime-agents.ts @@ -12,6 +12,7 @@ import { channels, } from "../db/schema"; import { agentAuthHeaders, authFromConfiguration } from "./auth-header"; +import { isManagedEndpoint } from "./managed-endpoint"; import type { AgentActor } from "./profile-types"; /** @@ -63,12 +64,10 @@ export function createRuntimeAgentLoader( if (isRemoteAgent && managedAgent) { // Config parses URLs, while package rows retain their original spelling. Compare both // in canonical form so scheme/host case cannot silently drop the deployment token. - const endpoint = managedEndpointIdentity(agent.endpoint); - const ours = - endpoint !== undefined && - [managedAgent.endpoint, managedAgent.alsoRun] - .filter((url): url is URL => url !== undefined) - .some((url) => endpoint === managedEndpointIdentity(url)); + const ours = isManagedEndpoint(agent.endpoint, [ + managedAgent.endpoint, + managedAgent.alsoRun, + ]); if (ours) { agent.headers = { ...agent.headers, @@ -92,18 +91,6 @@ export function createRuntimeAgentLoader( }; } -/** Keep the existing pathname slash tolerance without erasing query or fragment differences. */ -function managedEndpointIdentity(value: string | URL): string | undefined { - try { - const endpoint = new URL(value); - endpoint.pathname = endpoint.pathname.replace(/\/+$/, ""); - return endpoint.toString(); - } catch { - // An invalid stored URL is not a managed endpoint; it must not abort another agent's load. - return undefined; - } -} - function selectActiveAgents(database: Database, actor: AgentActor) { return database .select({ diff --git a/server/src/app.ts b/server/src/app.ts index dcf69de23..b30d0dc2b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1445,6 +1445,17 @@ export function createApp( // stores as running at this address — can be told apart from one a person hosts. config.managedAgent?.endpoint?.toString(), coworker?.shared?.use, + // So testing one of our own addresses knocks with the token the run will use, rather than + // reporting a refusal for a Bot this deployment started itself. + config.managedAgent + ? { + token: config.managedAgent.token, + endpoints: [ + config.managedAgent.endpoint, + config.managedAgent.alsoRun, + ].filter((url): url is URL => url !== undefined), + } + : undefined, ), ); // Choosing a coworker for an untagged message needs the same permission-filtered roster the diff --git a/server/tests/agent-connection-slow-agent.test.ts b/server/tests/agent-connection-slow-agent.test.ts new file mode 100644 index 000000000..e2469e045 --- /dev/null +++ b/server/tests/agent-connection-slow-agent.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { testAgentConnection } from "../src/agents/connection-test"; + +/** + * An agent that answers, then thinks for a long time. + * + * This is what an agentic harness does: it opens the stream with `RUN_STARTED` and then goes away + * to call a model, plan, and run tools, which can take a minute. The opening read stopped at a byte + * cap or the end of the stream, and neither arrives — so the probe sat on an open socket until the + * whole request timed out, and the abort surfaced as "The agent started answering and the + * connection broke." + * + * Nothing broke. The agent had already proved what the test exists to prove, in its first event. + */ +describe("an agent that opens its stream and keeps working", () => { + test("is judged on what it has already sent", async () => { + const agent = Bun.serve({ + port: 0, + fetch: () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"RUN_STARTED","threadId":"t","runId":"r"}\n\n', + ), + ); + // And then nothing, for longer than any probe should wait. Never closed: a run in + // progress holds its stream open. + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + }); + + try { + const started = Date.now(); + const result = await testAgentConnection( + `http://127.0.0.1:${agent.port}/`, + { allowPrivateHosts: true }, + ); + const elapsed = Date.now() - started; + + expect(result.ok).toBe(true); + if (result.ok) expect(result.events).toContain("RUN_STARTED"); + // The point of the change: it answers from the first event rather than waiting out a run. + expect(elapsed).toBeLessThan(5_000); + } finally { + agent.stop(true); + } + }); + + test("a stream that proves nothing is still read to the cap", async () => { + // The guard against stopping too early. Heartbeats and comments are not AG-UI events, so a + // stream of them must not be accepted as an agent on the strength of having sent bytes. + const agent = Bun.serve({ + port: 0, + fetch: () => + new Response(": keep-alive\n\n: keep-alive\n\n", { + headers: { "content-type": "text/event-stream" }, + }), + }); + + try { + const result = await testAgentConnection( + `http://127.0.0.1:${agent.port}/`, + { allowPrivateHosts: true }, + ); + expect(result.ok).toBe(false); + } finally { + agent.stop(true); + } + }); +}); diff --git a/server/tests/agent-test-connection-managed.test.ts b/server/tests/agent-test-connection-managed.test.ts new file mode 100644 index 000000000..176cbe880 --- /dev/null +++ b/server/tests/agent-test-connection-managed.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import { createAgentRoutes } from "../src/agents/routes"; +import type { AppVariables } from "../src/auth/guards"; + +/** + * Testing an address this deployment runs itself. + * + * The managed Bot, and any harness picked during setup, refuse every call that does not carry the + * deployment's own token — and nobody types that token anywhere. The run path attaches it by + * recognising the address as one of its own. The test button did not, so it knocked with no key, + * got a 401, and reported "The agent refused this request. If it needs a key, add it as a header." + * about a Bot that works perfectly. + * + * That is the one case where a wrong answer costs the most. The address was filled in by this + * deployment rather than typed by the person reading the verdict, so they have no way to tell a + * real refusal from this one, and the advice it offers — add a key — is advice to break it. + */ +describe("testing an address this deployment runs", () => { + const requireUser: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, + ) => { + context.set("actor", { + id: "user-1", + email: "user@openbot.test", + role: "user", + }); + await next(); + }; + + const TOKEN = "deployment-token"; + + /** A stand-in for `agent-antigravity`: the deployment's token, or 401. */ + function gatedAgent() { + return Bun.serve({ + port: 0, + fetch: (request: Request) => + request.headers.get("x-openbot-agent-token") === TOKEN + ? new Response('data: {"type":"RUN_STARTED"}\n\n', { + headers: { "content-type": "text/event-stream" }, + }) + : new Response("unauthorised", { status: 401 }), + }); + } + + function appFor(endpoints: readonly string[]) { + return createAgentRoutes( + {} as never, + requireUser, + true, + undefined, + new Set(), + undefined, + true, + endpoints[0], + undefined, + { token: TOKEN, endpoints }, + ); + } + + async function probe( + app: ReturnType, + endpoint: string, + ) { + const response = await app.request("http://openbot.test/test-connection", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ endpoint }), + }); + return (await response.json()) as { ok: boolean; status?: number }; + } + + test("knocks with the token the real run would use", async () => { + const agent = gatedAgent(); + const endpoint = `http://127.0.0.1:${agent.port}/`; + try { + expect(await probe(appFor([endpoint]), endpoint)).toMatchObject({ + ok: true, + }); + } finally { + agent.stop(true); + } + }); + + test("a trailing slash is not a different address", async () => { + // The run path treats these as the same address. A test that did not would answer 401 for an + // endpoint this deployment is about to dial successfully. + const agent = gatedAgent(); + const stored = `http://127.0.0.1:${agent.port}`; + try { + expect(await probe(appFor([stored]), `${stored}/`)).toMatchObject({ + ok: true, + }); + } finally { + agent.stop(true); + } + }); + + test("a harness picked during setup is ours too", async () => { + // Two addresses this deployment runs: the Bot in the box and the harness started beside it. + // Matching only the first is the bug this list exists to prevent, and it is the one the run + // path already had to fix. + const agent = gatedAgent(); + const picked = `http://127.0.0.1:${agent.port}/`; + try { + const app = appFor(["http://127.0.0.1:1/", picked]); + expect(await probe(app, picked)).toMatchObject({ ok: true }); + } finally { + agent.stop(true); + } + }); + + test("somebody else's address is never handed our token", async () => { + // The whole value of the token is that it is this deployment's. An address it does not run + // must be knocked on exactly as before, however the person spelled the url. + let offered: string | null = null; + const agent = Bun.serve({ + port: 0, + fetch: (request: Request) => { + offered = request.headers.get("x-openbot-agent-token"); + return new Response('data: {"type":"RUN_STARTED"}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + try { + const app = appFor(["http://127.0.0.1:1/"]); + await probe(app, `http://127.0.0.1:${agent.port}/`); + expect(offered).toBeNull(); + } finally { + agent.stop(true); + } + }); + + test("a deployment with no managed Bot is unchanged", async () => { + // Nothing to recognise and no token to offer: the probe goes out bare, as it always did. + let offered: string | null = null; + const agent = Bun.serve({ + port: 0, + fetch: (request: Request) => { + offered = request.headers.get("x-openbot-agent-token"); + return new Response('data: {"type":"RUN_STARTED"}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + try { + const app = createAgentRoutes({} as never, requireUser, true); + await probe(app, `http://127.0.0.1:${agent.port}/`); + expect(offered).toBeNull(); + } finally { + agent.stop(true); + } + }); +}); diff --git a/shared/harnesses.json b/shared/harnesses.json index 2ce6fd7a2..1e695bb0c 100644 --- a/shared/harnesses.json +++ b/shared/harnesses.json @@ -1,5 +1,5 @@ { - "_readme": "One file every language reads: the agent harnesses OpenBot knows by name. The desktop setup window reads it through Rust (desktop/src-tauri/src/harness.rs, include_str!) to decide which image to run; the web app's New Bot dialog reads it through shared/harnesses.ts to name the frameworks a person might already be running. Ranked stars-first, with downloads as the sanity check, because each misleads alone. Anything the AG-UI table marks In Progress is left out — OpenAI's Agents SDK, AWS Bedrock Agents and Cloudflare Agents are all In Progress, and offering a harness that cannot answer is worse than a shorter list. 'image' is the DIRECTORY the image is built from, not the id: a release publishes openbot- from the Dockerfile paths in the tree, and four rows have an id that differs from their folder. 'mark' is the file stem under shared/marks/, or null where no maintained set has one — nothing is ever invented to fill the gap, see shared/marks/README.md. 'webCreate' is false for a row the web create API cannot actually make work. 'webSummary' overrides 'summary' in the web dialog, and exists only where the installer's sentence is false on a surface that installs nothing.", + "_readme": "One file every language reads: the agent harnesses OpenBot knows by name. The desktop setup window reads it through Rust (desktop/src-tauri/src/harness.rs, include_str!) to decide which image to run; the web app's New Bot dialog reads it through shared/harnesses.ts to name the frameworks a person might already be running. Ranked stars-first, with downloads as the sanity check, because each misleads alone. Anything the AG-UI table marks In Progress is left out \u2014 OpenAI's Agents SDK, AWS Bedrock Agents and Cloudflare Agents are all In Progress, and offering a harness that cannot answer is worse than a shorter list. 'image' is the DIRECTORY the image is built from, not the id: a release publishes openbot- from the Dockerfile paths in the tree, and four rows have an id that differs from their folder. 'mark' is the file stem under shared/marks/, or null where no maintained set has one \u2014 nothing is ever invented to fill the gap, see shared/marks/README.md. 'webCreate' is false for a row the web create API cannot actually make work. 'webSummary' overrides 'summary' in the web dialog, and exists only where the installer's sentence is false on a surface that installs nothing.", "harnesses": [ { "id": "crewai", @@ -71,6 +71,20 @@ "mark": "google-adk", "webCreate": true }, + { + "id": "antigravity", + "name": "Google Antigravity", + "summary": "Google's agentic harness. Plans, edits and runs things itself.", + "webSummary": null, + "image": "agent-antigravity", + "port": 4214, + "healthPath": "/health", + "runPath": "", + "credential": "google", + "maintainer": "first-party", + "mark": "antigravity", + "webCreate": true + }, { "id": "pydantic-ai", "name": "Pydantic AI", diff --git a/shared/harnesses.test.ts b/shared/harnesses.test.ts index a75567ff4..1e6f0fb00 100644 --- a/shared/harnesses.test.ts +++ b/shared/harnesses.test.ts @@ -8,6 +8,7 @@ test("carries every row the desktop picker offers, in its order", () => { "agno", "langgraph", "google-adk", + "antigravity", "pydantic-ai", "microsoft-agent-framework", "claude-agent-sdk", @@ -62,3 +63,26 @@ test("every row an image publishes names the directory it is built from", () => expect(crewai?.healthPath).toBe("/health"); expect(crewai?.port).toBe(4202); }); + +/* + * Antigravity only ever runs on Gemini: its README says the harness requires a Gemini API key + * wherever `base_url` points. `any-provider` would let a deployment holding only an OpenAI key + * install it and get a Bot that cannot answer — the shape of failure `webCreate: false` exists to + * stop for Mastra. + */ +test("marks Antigravity as needing Google's key rather than any provider's", () => { + const antigravity = HARNESSES.find((h) => h.id === "antigravity"); + expect(antigravity?.credential).toBe("google"); + expect(antigravity?.name).toBe("Google Antigravity"); + expect(antigravity?.image).toBe("agent-antigravity"); + expect(antigravity?.port).toBe(4214); + expect(antigravity?.mark).toBe("antigravity"); + expect(antigravity?.webCreate).toBe(true); +}); + +test("gives every row a credential the type actually names", () => { + const known = ["any-provider", "anthropic", "google", "their-endpoint"]; + for (const harness of HARNESSES) { + expect(known).toContain(harness.credential); + } +}); diff --git a/shared/harnesses.ts b/shared/harnesses.ts index c70979149..fa3442e06 100644 --- a/shared/harnesses.ts +++ b/shared/harnesses.ts @@ -13,7 +13,19 @@ import catalogue from "./harnesses.json"; * bun workspace, so `include_str!` takes the JSON directly. That is why the data lives in `.json` * and only the typing lives here. */ -export type Credential = "any-provider" | "anthropic" | "their-endpoint"; +/** + * What a harness needs before it can answer. + * + * `google` is its own variant rather than `any-provider` because Antigravity's harness requires a + * Gemini key wherever its base url points — "any provider the person already has" is exactly the + * promise it cannot keep, and a deployment holding only an OpenAI key would install a Bot that + * never answers. + */ +export type Credential = + | "any-provider" + | "anthropic" + | "google" + | "their-endpoint"; export type Maintainer = "first-party" | "partnership" | "community"; export type Harness = { diff --git a/shared/marks/antigravity.svg b/shared/marks/antigravity.svg new file mode 100644 index 000000000..784fc63db --- /dev/null +++ b/shared/marks/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/shared/model-providers.json b/shared/model-providers.json index 58b2c6785..5de078e84 100644 --- a/shared/model-providers.json +++ b/shared/model-providers.json @@ -25,6 +25,7 @@ "agent-langgraph": { "provider": "openai", "model": "gpt-5.5" }, "agent-mastra": { "provider": "openai", "model": "gpt-4o-mini" }, "agent-adk": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-antigravity": { "provider": "google", "model": "gemini-3.6-flash" }, "agent-ag2": { "provider": "openai", "model": "gpt-4o-mini" }, "agent-agno": { "provider": "openai", "model": "gpt-5.5" }, "agent-crewai": { "provider": "openai", "model": "gpt-5.5" },