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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ jobs:
. .venv-llamaindex/bin/activate
python -m pip install --requirement agent-llamaindex/requirements.txt --requirement agent-llamaindex/requirements-test.txt
python -m pytest agent-llamaindex/tests -q
- name: Agno model-choice regression
run: |
set -euo pipefail
python -m venv .venv-agno
. .venv-agno/bin/activate
python -m pip install --requirement agent-agno/requirements.txt --requirement agent-agno/requirements-test.txt
python -m pytest agent-agno/tests -q
- run: bun install --frozen-lockfile
- run: bun test tests/compose.test.ts
- run: docker compose --env-file /dev/null --profile harness config --format json >/dev/null
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### The Agno Bot answers on an OpenAI key

Picked with an OpenAI key, the Agno Bot failed every run before reaching OpenAI. Agno sends a
temperature and a `top_p` with each request, and LiteLLM refuses both for `gpt-5.5`, the model
Compose passes when the setup screen names none, so the run ended in `UnsupportedParamsError`. A
parameter the model does not take is now dropped instead, the way the LlamaIndex Bot already does
it. The Anthropic key and an OpenAI-compatible endpoint behave as before.

## 0.0.13

### Fresh desktop setup installs its runtime before sign-in
Expand Down
2 changes: 2 additions & 0 deletions agent-agno/requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
httpx==0.28.1
pytest==9.0.2
6 changes: 5 additions & 1 deletion agent-agno/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ def _model_id() -> str:
# In memory, because a Bot's history lives in OpenBot's database and not in the harness. Two
# places remembering the same conversation is how they come to disagree.
db=InMemoryDb(),
model=LiteLLM(id=_model_id()),
# `drop_params`, because Agno sends a temperature and a `top_p` on every request and LiteLLM
# refuses both for a reasoning model, the default `gpt-5.5` among them: every run on an OpenAI
# key failed before it reached OpenAI. A parameter a model does not take is dropped instead,
# for this one client, as the LlamaIndex Bot does.
model=LiteLLM(id=_model_id(), request_params={"drop_params": True}),
# No role, goal or backstory invented on somebody's behalf. A Bot answers the question it is
# asked, and anybody who wants a persona sets one in OpenBot where the rest of them live.
instructions="Answer the question you are asked, briefly and correctly.",
Expand Down
186 changes: 186 additions & 0 deletions agent-agno/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import importlib
import json
import socket
import sys
import threading
import time
from pathlib import Path

import pytest
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.testclient import TestClient

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

TOKEN = "test-token"
RUN = {
"threadId": "thread-1",
"runId": "run-1",
"state": {},
"messages": [{"id": "m1", "role": "user", "content": "Say hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
}


def _sse(events):
async def stream():
for event in events:
yield event

return StreamingResponse(stream(), media_type="text/event-stream")


def _provider_app(seen):
app = FastAPI()

@app.post("/v1/chat/completions")
async def openai_chat(request: Request):
body = await request.json()
seen.append(("openai", body["model"]))
if not body.get("stream"):
return JSONResponse(
{
"id": "c",
"object": "chat.completion",
"created": 0,
"model": body["model"],
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "hello"},
}
],
}
)
chunk = {
"id": "c",
"object": "chat.completion.chunk",
"created": 0,
"model": body["model"],
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": "hello"},
"finish_reason": None,
}
],
}
done = {**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
return _sse(
[f"data: {json.dumps(chunk)}\n\n", f"data: {json.dumps(done)}\n\n", "data: [DONE]\n\n"]
)

@app.post("/v1/messages")
async def anthropic_messages(request: Request):
body = await request.json()
seen.append(("anthropic", body["model"]))
message = {
"id": "msg",
"type": "message",
"role": "assistant",
"model": body["model"],
"stop_sequence": None,
}
if not body.get("stream"):
return JSONResponse(
{
**message,
"content": [{"type": "text", "text": "hello"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
}
)
events = [
("message_start", {"type": "message_start", "message": {**message, "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello"}}),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 1}}),
("message_stop", {"type": "message_stop"}),
]
return _sse([f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events])

return app


@pytest.fixture
def provider():
seen = []
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = uvicorn.Server(
uvicorn.Config(_provider_app(seen), host="127.0.0.1", port=port, log_level="error")
)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
time.sleep(0.01)
yield f"http://127.0.0.1:{port}", seen
server.should_exit = True
thread.join(timeout=10)


CHOICES = {
"an Anthropic key": (
lambda base: {
"BOT_PROVIDER": "anthropic",
"BOT_MODEL": "claude-sonnet-4-5",
"ANTHROPIC_API_KEY": "test-key",
"ANTHROPIC_BASE_URL": base,
"OPENAI_API_KEY": "",
"OPENAI_BASE_URL": "",
},
("anthropic", "claude-sonnet-4-5"),
),
"an OpenAI-compatible endpoint": (
lambda base: {
"BOT_PROVIDER": "",
"BOT_MODEL": "local-model",
"OPENAI_API_KEY": "no-key-needed",
"OPENAI_BASE_URL": f"{base}/v1",
"ANTHROPIC_API_KEY": "",
},
("openai", "local-model"),
),
# The default: Compose passes `gpt-5.5` when the model screen names no model, and Agno sends a
# temperature and a `top_p` on every request, which LiteLLM refuses for that reasoning model.
"an OpenAI key": (
lambda base: {
"BOT_PROVIDER": "",
"BOT_MODEL": "gpt-5.5",
"OPENAI_API_KEY": "test-key",
"OPENAI_BASE_URL": f"{base}/v1",
"ANTHROPIC_API_KEY": "",
},
("openai", "gpt-5.5"),
),
}


@pytest.mark.parametrize("choice", list(CHOICES))
def test_a_run_reaches_the_model_the_setup_screen_chose(monkeypatch, provider, choice):
base, seen = provider
environment, expected = CHOICES[choice]
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setenv("MANAGED_AGENT_TOKEN", TOKEN)
for key, value in environment(base).items():
monkeypatch.setenv(key, value)

from src import main

main = importlib.reload(main)
response = TestClient(main.app).post(
"/agui", json=RUN, headers={"x-openbot-agent-token": TOKEN}
)

assert response.status_code == 200
assert '"RUN_FINISHED"' in response.text
assert '"RUN_ERROR"' not in response.text
assert seen == [expected]
Loading