From 390855757f93b45942a8552863f6b967344538b8 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 09:25:56 -0300 Subject: [PATCH 01/11] Let a harness need one vendor's key rather than any provider's Every harness so far either takes whatever provider the deployment chose or brings its own address, so `credential` had three values and `any-provider` was the default answer. Antigravity is the first that is neither: its bundled harness requires a Gemini key wherever its base url points, so offering it as "any provider you already have" would install it against an OpenAI-only deployment and produce a Bot that starts, accepts work, and fails every call. That is the failure `webCreate` already exists to prevent on the web side, so this is the same decision made one level down: `google` is its own credential, the desktop picker says "Needs a Gemini key" on the row, and the test that every row's credential is one the type names keeps the two lists from drifting. Nothing changes for the rows that were already there. --- desktop/src-tauri/src/harness.rs | 7 +++++++ desktop/src/HarnessPicker.tsx | 3 +++ shared/harnesses.test.ts | 24 ++++++++++++++++++++++++ shared/harnesses.ts | 14 +++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 3493bdea5e..23961cd708 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, } diff --git a/desktop/src/HarnessPicker.tsx b/desktop/src/HarnessPicker.tsx index 7fb430b521..520f807a7f 100644 --- a/desktop/src/HarnessPicker.tsx +++ b/desktop/src/HarnessPicker.tsx @@ -137,6 +137,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/shared/harnesses.test.ts b/shared/harnesses.test.ts index a75567ff48..1e6f0fb004 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 c709791499..fa3442e061 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 = { From 05b13e70a63000865c12cbd5bbc3ea3f3a5b2454 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 09:26:19 -0300 Subject: [PATCH 02/11] Offer Google Antigravity as a Bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AG-UI publishes an Antigravity integration, so this is the thirteenth harness and mostly follows the twelve before it: a small FastAPI service, the shared token gate, an open /health for Compose to poll, a row in the catalogue, and the vendored mark from the same MIT set as the rest. Two things are not like the others. The provider is PINNED. Every other Bot reads `BOT_PROVIDER`, because they all reach their model through LiteLLM or an equivalent and moving them is a setting. Antigravity's SDK drives a bundled Go harness that wants a Gemini key wherever its base url points, so a deployment that moved its other Bots to OpenAI must not drag this one along — `bot_settings(..., pinned_provider="google")` is what stops it. The model is still the deployment's to choose: `BOT_MODEL` names which Gemini, and the pin only holds the provider. The image is glibc, not musl, and the Dockerfile says why: the SDK ships a platform wheel bundling a Go `localharness` built against manylinux_2_17, and alpine cannot load it. Proved rather than assumed — the linux/amd64 image was built and run, /health answered, a POST without the server token was refused 401, and the same POST with it reached the AG-UI endpoint. `.github/published-images.json` gains the image, which is the file CI checks against the Dockerfiles in the tree and the release derives its build matrix from, so one entry wires both. Worth knowing for whoever reviews the security of this: that Go harness does real file and shell work. In this image it has no volumes and no host mount, so its reach is the container — but it sits outside agent-computer's barriers, authorisation and handoff entirely, which is where every other Bot's computer work goes. --- .github/published-images.json | 3 +- .github/workflows/ci.yml | 7 +++ agent-antigravity/Dockerfile | 23 ++++++++ agent-antigravity/requirements-test.txt | 2 + agent-antigravity/requirements.txt | 3 + agent-antigravity/src/main.py | 67 +++++++++++++++++++++ agent-antigravity/tests/test_main.py | 65 ++++++++++++++++++++ agent-antigravity/tests/test_model_spec.py | 45 ++++++++++++++ shared/harnesses.json | 16 ++++- shared/marks/antigravity.svg | 1 + shared/model-providers.json | 69 ++++++++++++++++++---- 11 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 agent-antigravity/Dockerfile create mode 100644 agent-antigravity/requirements-test.txt create mode 100644 agent-antigravity/requirements.txt create mode 100644 agent-antigravity/src/main.py create mode 100644 agent-antigravity/tests/test_main.py create mode 100644 agent-antigravity/tests/test_model_spec.py create mode 100644 shared/marks/antigravity.svg diff --git a/.github/published-images.json b/.github/published-images.json index d460e1f335..b877845cac 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 5d33656e05..81a00accee 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/agent-antigravity/Dockerfile b/agent-antigravity/Dockerfile new file mode 100644 index 0000000000..cd96befd7d --- /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 0000000000..a91d60c68a --- /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 0000000000..5430420f23 --- /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 0000000000..1722483350 --- /dev/null +++ b/agent-antigravity/src/main.py @@ -0,0 +1,67 @@ +"""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. +""" + +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 bot_settings + +from ag_ui_antigravity import AntigravityAgent, create_antigravity_app +from fastapi import Request +from fastapi.responses import JSONResponse + +TOKEN_HEADER = "x-openbot-agent-token" + + +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 + + +app = create_antigravity_app( + AntigravityAgent( + model=_model(), + # Read from the environment by the SDK itself when absent. Named here so the failure when + # it is missing is this Bot's, at start-up, rather than a stream that opens and dies. + api_key=os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"), + ), + 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 0000000000..6c1d7471e6 --- /dev/null +++ b/agent-antigravity/tests/test_main.py @@ -0,0 +1,65 @@ +"""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-2.5-flash" diff --git a/agent-antigravity/tests/test_model_spec.py b/agent-antigravity/tests/test_model_spec.py new file mode 100644 index 0000000000..347ad93e2a --- /dev/null +++ b/agent-antigravity/tests/test_model_spec.py @@ -0,0 +1,45 @@ +"""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-2.5-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) + monkeypatch.setenv("BOT_MODEL", "gemini-3.6-flash") + assert bot_settings(BOT_ID, pinned_provider=PINNED) == BotSettings( + provider="google", model="gemini-3.6-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 diff --git a/shared/harnesses.json b/shared/harnesses.json index 2ce6fd7a28..1e695bb0cb 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/marks/antigravity.svg b/shared/marks/antigravity.svg new file mode 100644 index 0000000000..784fc63dbe --- /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 58b2c6785d..46da8bb66b 100644 --- a/shared/model-providers.json +++ b/shared/model-providers.json @@ -21,18 +21,61 @@ } }, "bots": { - "agent-bot": { "provider": "openai", "model": "gpt-5.5" }, - "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-ag2": { "provider": "openai", "model": "gpt-4o-mini" }, - "agent-agno": { "provider": "openai", "model": "gpt-5.5" }, - "agent-crewai": { "provider": "openai", "model": "gpt-5.5" }, - "agent-langgraph-agui": { "provider": "openai", "model": "gpt-4o-mini" }, - "agent-langroid": { "provider": "openai", "model": "gpt-4o-mini" }, - "agent-llamaindex": { "provider": "openai", "model": "gpt-5.5" }, - "agent-microsoft": { "provider": "openai", "model": "gpt-4o-mini" }, - "agent-pydantic-ai": { "provider": "openai", "model": "gpt-4o-mini" }, - "agent-strands": { "provider": "openai", "model": "gpt-4o-mini" } + "agent-bot": { + "provider": "openai", + "model": "gpt-5.5" + }, + "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-2.5-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" + }, + "agent-langgraph-agui": { + "provider": "openai", + "model": "gpt-4o-mini" + }, + "agent-langroid": { + "provider": "openai", + "model": "gpt-4o-mini" + }, + "agent-llamaindex": { + "provider": "openai", + "model": "gpt-5.5" + }, + "agent-microsoft": { + "provider": "openai", + "model": "gpt-4o-mini" + }, + "agent-pydantic-ai": { + "provider": "openai", + "model": "gpt-4o-mini" + }, + "agent-strands": { + "provider": "openai", + "model": "gpt-4o-mini" + } } } From 5b422cc4cc362de3fda5fd7afa43a95ef42be46d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 09:26:30 -0300 Subject: [PATCH 03/11] Ignore the per-Bot virtual environments CI leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI gives each Python Bot an environment named after it — `.venv-adk`, `.venv-antigravity` — and `.gitignore` only knew the bare `.venv` and `venv` spellings. Running those same commands locally, which is the obvious way to check a Python Bot before pushing, left hundreds of megabytes of untracked platform binaries in the tree. Biome does not read `.gitignore`, so it needs telling separately: linting a freshly made environment reported fifteen errors inside urllib3 and failed `bun run lint` for reasons that had nothing to do with the change in hand. --- .gitignore | 5 ++++- biome.json | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e6f9f1c76c..4a7ca18abb 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/biome.json b/biome.json index 6493b47868..8fedba4cb9 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", From 96ff00a84eb8b4143b97c2f337613f2392f60614 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 10:37:50 -0300 Subject: [PATCH 04/11] Send Antigravity at the Gemini the deployment names, not only Google's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `google` row in `shared/model-providers.json` declares a `base_url_variable`, and this Bot ignored it: it read `GOOGLE_API_KEY` by hand and had no way to reach a Gemini-compatible endpoint that was not Google's. Both variable names now come from the row, so renaming either is one edit to the file every language reads. A base url is spelled as `endpoint=GeminiAPIEndpoint(...)`. `AntigravityAgent(base_url=...)` is a different and mutually exclusive path, meant for unauthenticated local servers, and it drops the key — so the test asserts the key came along rather than only grepping the source. That url is the API ROOT, with no version path. The harness appends `/v1beta/models/:streamGenerateContent` itself, and a url carrying the version 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. --- agent-antigravity/src/main.py | 61 ++++++++++++++++++---- agent-antigravity/tests/test_main.py | 57 ++++++++++++++++++++ agent-antigravity/tests/test_model_spec.py | 15 ++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/agent-antigravity/src/main.py b/agent-antigravity/src/main.py index 1722483350..690242b1dc 100644 --- a/agent-antigravity/src/main.py +++ b/agent-antigravity/src/main.py @@ -10,6 +10,17 @@ 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 @@ -20,14 +31,18 @@ # 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 bot_settings +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. @@ -39,15 +54,41 @@ def _model() -> str: return bot_settings("agent-antigravity", pinned_provider="google").model -app = create_antigravity_app( - AntigravityAgent( - model=_model(), - # Read from the environment by the SDK itself when absent. Named here so the failure when - # it is missing is this Bot's, at start-up, rather than a stream that opens and dies. - api_key=os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"), - ), - path="/", -) +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") diff --git a/agent-antigravity/tests/test_main.py b/agent-antigravity/tests/test_main.py index 6c1d7471e6..06027d5206 100644 --- a/agent-antigravity/tests/test_main.py +++ b/agent-antigravity/tests/test_main.py @@ -63,3 +63,60 @@ def test_the_agent_is_built_on_the_pinned_provider(monkeypatch): importlib.reload(src.main) assert src.main._model() == "gemini-2.5-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 index 347ad93e2a..bdcf175459 100644 --- a/agent-antigravity/tests/test_model_spec.py +++ b/agent-antigravity/tests/test_model_spec.py @@ -43,3 +43,18 @@ def test_the_model_is_still_the_deployments_to_choose(monkeypatch): 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 From d8be055c1c798e76bf154db898afd359ac4068b0 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 10:37:57 -0300 Subject: [PATCH 05/11] Name a brokered app without naming the broker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Composio tool call read "Gmail fetch emails" with a muted "composio-gmail" beside it. The broker is how the credential is held, which is an administrator's fact on the Bot's access page, not something the person who asked about their email needs on screen — the same argument this file already makes for relabelling research calls. Two things kept it there. The prefix was never stripped, and the rule that drops a server the label already names skips the label's first word, so a verb cannot be mistaken for the server. Composio publishes `__`, so the app always leads and was always the word being skipped. The app is still shown when the action does not name it — `FETCH_EMAILS` keeps `gmail` as its detail. Only the broker is dropped, never what was acted upon. --- app/src/lib/plugins/tool-name.ts | 37 +++++++++++++++++++++++++++----- app/tests/tool-name.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index 02e27967d1..c39d7b10b9 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 cf5b1308cd..7a9c5bbf4d 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" }); From 586b2ce09ec36652d2269d31763f4d8a2cb841a5 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 10:54:10 -0300 Subject: [PATCH 06/11] Run Antigravity on a Gemini that still exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gemini-2.5-flash` is retired: Google answers a request for it with 404 "no longer available to new users", so this Bot as merged would have failed every run on a fresh deployment. Measured against a real key, not read off a changelog — `gemini-3.6-flash` answers through the harness. The provider row's own `default_model` is stale in the same way, but it is not this Bot's: the pin means `bot_settings` reads the `bots` entry, never the provider fallback. Left alone here rather than widened into a change to every Bot a deployment might move to Google. The `bots` block is back to one object per line. Adding an entry had reformatted all thirteen, which made a one-line change read as fifty-six; biome accepts either, so the churn bought nothing. The override test now names a model the file does not, because an override asserting the row's own value passes whether or not the environment is read. --- agent-antigravity/tests/test_main.py | 2 +- agent-antigravity/tests/test_model_spec.py | 8 ++- shared/model-providers.json | 70 +++++----------------- 3 files changed, 20 insertions(+), 60 deletions(-) diff --git a/agent-antigravity/tests/test_main.py b/agent-antigravity/tests/test_main.py index 06027d5206..7d144f1505 100644 --- a/agent-antigravity/tests/test_main.py +++ b/agent-antigravity/tests/test_main.py @@ -62,7 +62,7 @@ def test_the_agent_is_built_on_the_pinned_provider(monkeypatch): import src.main importlib.reload(src.main) - assert src.main._model() == "gemini-2.5-flash" + assert src.main._model() == "gemini-3.6-flash" def _rebuilt(): diff --git a/agent-antigravity/tests/test_model_spec.py b/agent-antigravity/tests/test_model_spec.py index bdcf175459..0313507d5e 100644 --- a/agent-antigravity/tests/test_model_spec.py +++ b/agent-antigravity/tests/test_model_spec.py @@ -20,7 +20,7 @@ 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-2.5-flash" + provider="google", model="gemini-3.6-flash" ) @@ -34,9 +34,11 @@ def test_the_provider_is_pinned_against_a_deployment_that_moved_everything_else( 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) - monkeypatch.setenv("BOT_MODEL", "gemini-3.6-flash") + # 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.6-flash" + provider="google", model="gemini-3.8-flash" ) diff --git a/shared/model-providers.json b/shared/model-providers.json index 46da8bb66b..5de078e848 100644 --- a/shared/model-providers.json +++ b/shared/model-providers.json @@ -21,61 +21,19 @@ } }, "bots": { - "agent-bot": { - "provider": "openai", - "model": "gpt-5.5" - }, - "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-2.5-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" - }, - "agent-langgraph-agui": { - "provider": "openai", - "model": "gpt-4o-mini" - }, - "agent-langroid": { - "provider": "openai", - "model": "gpt-4o-mini" - }, - "agent-llamaindex": { - "provider": "openai", - "model": "gpt-5.5" - }, - "agent-microsoft": { - "provider": "openai", - "model": "gpt-4o-mini" - }, - "agent-pydantic-ai": { - "provider": "openai", - "model": "gpt-4o-mini" - }, - "agent-strands": { - "provider": "openai", - "model": "gpt-4o-mini" - } + "agent-bot": { "provider": "openai", "model": "gpt-5.5" }, + "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" }, + "agent-langgraph-agui": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-langroid": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-llamaindex": { "provider": "openai", "model": "gpt-5.5" }, + "agent-microsoft": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-pydantic-ai": { "provider": "openai", "model": "gpt-4o-mini" }, + "agent-strands": { "provider": "openai", "model": "gpt-4o-mini" } } } From f0be6a02278461c62cb0943f72de4df190c7061e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 11:27:09 -0300 Subject: [PATCH 07/11] Knock on our own addresses with our own token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed Bot and any harness picked during setup refuse every call that does not carry the deployment's token, and nobody types that token anywhere: the run path attaches it by recognising the address. The connection test did not, so testing the Bot in the box answered "The agent refused this request. If it needs a key, add it as a header." — a refusal reported for a Bot that works, and advice that would have broken it. That is the worst case for a wrong verdict. The address was filled in by this deployment rather than typed by the person reading the result, so they cannot tell this from a real refusal. The comparison moves to `managed-endpoint.ts` and both paths call it. They had already drifted once in the other direction — matching a single endpoint left the picked harness registered, routed to, and answering 401 to everything — so the rule is written down once rather than twice. An address this deployment does not run is still knocked on bare. The token's whole value is that it is ours, and the tests pin that alongside the trailing-slash case and a deployment with no managed Bot at all. --- server/src/agents/managed-endpoint.ts | 46 ++++++ server/src/agents/routes.ts | 37 ++++- server/src/agents/runtime-agents.ts | 23 +-- server/src/app.ts | 11 ++ .../agent-test-connection-managed.test.ts | 156 ++++++++++++++++++ 5 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 server/src/agents/managed-endpoint.ts create mode 100644 server/tests/agent-test-connection-managed.test.ts diff --git a/server/src/agents/managed-endpoint.ts b/server/src/agents/managed-endpoint.ts new file mode 100644 index 0000000000..f5539babd2 --- /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 1aae47c718..bd716b2deb 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 6e12e7ac54..6831d81ac4 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 dcf69de230..b30d0dc2b9 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-test-connection-managed.test.ts b/server/tests/agent-test-connection-managed.test.ts new file mode 100644 index 0000000000..176cbe8800 --- /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); + } + }); +}); From 72afe9cdf411ccbe01ae571fbe65f8c790ab1069 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 11:27:15 -0300 Subject: [PATCH 08/11] Judge an agent on what it has already sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the token attached the test reached the agent, got its 200, and still failed: "The agent started answering and the connection broke." Nothing broke. The opening read stopped at an eight kilobyte cap or the end of the stream, and an agentic harness supplies neither — Antigravity opens with `RUN_STARTED`, ninety bytes, then goes away to call a model and run tools while holding the stream open. The read waited out the whole run and was cut off by the request timeout. It now stops at the first recognised AG-UI event, which is the entire question the test asks. Events rather than bytes, deliberately: a stream of SSE comments or heartbeats would satisfy a "something arrived" test while being no evidence of an agent at all. There is a case for that. --- server/src/agents/connection-test.ts | 14 ++++ .../tests/agent-connection-slow-agent.test.ts | 74 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 server/tests/agent-connection-slow-agent.test.ts diff --git a/server/src/agents/connection-test.ts b/server/src/agents/connection-test.ts index b310558db7..5648f78398 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/tests/agent-connection-slow-agent.test.ts b/server/tests/agent-connection-slow-agent.test.ts new file mode 100644 index 0000000000..e2469e0456 --- /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); + } + }); +}); From e378b2dbbf56ca6f94e4cc0855a42da13092901f Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 11:36:40 -0300 Subject: [PATCH 09/11] Teach the desktop picker the credential it is already drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tile reads `credential === "google"` to say "Needs a Gemini key", but the type it reads from still listed three variants, so the comparison could never be true and `tsc` refused it. The desktop app is outside the workspace and reads the catalogue over the Tauri boundary as JSON, so it cannot import the union from shared/harnesses.ts — it keeps its own copy, and adding a variant means adding it in both places. Said so above the type, since nothing else will. `cargo fmt` on the catalogue parse, which rustfmt wanted joined onto one line. Both were missed locally for the same reason: `bun run typecheck` covers the workspace, and desktop is not in it. --- desktop/src-tauri/src/harness.rs | 5 ++--- desktop/src/HarnessPicker.tsx | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/harness.rs b/desktop/src-tauri/src/harness.rs index 23961cd708..0588d45baf 100644 --- a/desktop/src-tauri/src/harness.rs +++ b/desktop/src-tauri/src/harness.rs @@ -194,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 520f807a7f..fd26dece4c 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; From 1672f3ce7b42954749d2724dff3be5666a3e7e9d Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 11:47:50 -0300 Subject: [PATCH 10/11] Hold litellm where CrewAI's mcp can still satisfy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CrewAI run started failing 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. Nothing in this repository changed: `agent-crewai/requirements.txt` pinned nothing, and litellm 1.106.0 began importing a symbol that exists only in mcp 2.x while ag-ui-crewai 1.0.0 requires mcp<2. The two cannot both be satisfied. Held litellm rather than raising mcp, because mcp>=2 is reachable only by letting the resolver walk ag-ui-crewai back to 0.3.1 and crewai to 1.6.1 — a silent downgrade of the harness under test. 1.105.1 does not reference the symbol anywhere and leaves every other version where CI already had it: ag-ui-crewai 1.0.0, crewai 1.15.28, mcp 1.28.1. Resolved and imported locally to check. Only this harness is affected. The other five that name litellm resolve without mcp at all, so the import is never reached; ADK was checked directly because it runs next in the same job. Unrelated to the rest of this branch, and in it only because the job stops at the first harness that fails — which left the Antigravity step skipped rather than run. --- agent-crewai/requirements.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-crewai/requirements.txt b/agent-crewai/requirements.txt index 57bf9f8af3..f089be47bb 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 From 178830223367cfe5dd2619934715762b795a7a40 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Mon, 19 Oct 2026 11:58:34 -0300 Subject: [PATCH 11/11] Hold litellm in every harness that reaches for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CrewAI pin fixed CrewAI and the job walked on to LlamaIndex, which failed the same way with a different face: `APIConnectionError: OpenAIException - No module named 'mcp'`. litellm 1.106.0 reaches for mcp during a model call and reports whatever it finds as an OpenAIException, so the error arrives as a connection fault rather than the missing import it is. Both ends of that break: an older mcp pinned by another dependency gives `cannot import name 'CacheableResult' from 'mcp.types'`, and no mcp at all gives `No module named 'mcp'`. I had checked ADK, seen mcp absent, and concluded it was safe — absent is the second failure, not the absence of one, so the remaining five are pinned together rather than one per CI round trip. Verified by resolving and running the suite that actually failed: agent-llamaindex on litellm 1.105.1, 10 passed. --- agent-adk/requirements.txt | 8 +++++++- agent-agno/requirements.txt | 8 +++++++- agent-langroid/requirements.txt | 7 +++++++ agent-llamaindex/requirements.txt | 7 +++++++ agent-strands/requirements.txt | 8 +++++++- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/agent-adk/requirements.txt b/agent-adk/requirements.txt index 0841b663d8..8b5befab01 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 b33b313bae..af537af602 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-langroid/requirements.txt b/agent-langroid/requirements.txt index 028af3797c..309b26d7b1 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 e7e7dac579..85e103da93 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 91c4a449c0..6075b67ba4 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]