Skip to content

Commit 03c08f0

Browse files
authored
Merge pull request #81 from OpenBB-finance/feat/custom-feats
2 parents 34bec94 + 39e8a41 commit 03c08f0

6 files changed

Lines changed: 364 additions & 1 deletion

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Vanilla Agent with Custom Features
2+
3+
This is a simple example agent that demonstrates how to detect and report which custom features are enabled or disabled in the OpenBB Workspace UI.
4+
5+
## What it does
6+
7+
This agent:
8+
- Greets users with a friendly hello message
9+
- Reports the status of custom features (Deep Research and Web Search)
10+
- Shows whether each feature is enabled (✅) or disabled (❌) based on the UI settings
11+
12+
## Features
13+
14+
The agent defines two custom features in its configuration:
15+
- **Deep Research**: Allows the agent to perform deep research (default: disabled)
16+
- **Web Search**: Allows the agent to search the web (default: enabled)
17+
18+
Users can toggle these features on/off in the OpenBB Workspace UI, and the agent will detect and report the current status.
19+
20+
## Getting started
21+
22+
### Prerequisites
23+
24+
Ensure you have poetry, a tool for dependency management and packaging in Python, as well as your OpenAI API key.
25+
26+
### Installation and Running
27+
28+
1. Clone this repository to your local machine.
29+
30+
2. Set the OpenAI API key as an environment variable in your .bashrc or .zshrc file:
31+
32+
``` sh
33+
# in .zshrc or .bashrc
34+
export OPENAI_API_KEY=<your-api-key>
35+
```
36+
37+
3. Install the necessary dependencies:
38+
39+
``` sh
40+
poetry install --no-root
41+
```
42+
43+
4. Start the API server:
44+
45+
``` sh
46+
cd 37-vanilla-agent-custom-features
47+
poetry run uvicorn vanilla_agent_custom_features.main:app --port 7777 --reload
48+
```
49+
50+
This command runs the FastAPI application, making it accessible on your network.
51+
52+
### Testing the Agent
53+
54+
The example agent has a small, basic test suite to ensure it's working correctly. As you develop your agent, you are highly encouraged to expand these tests.
55+
56+
You can run the tests with:
57+
58+
```sh
59+
pytest tests
60+
```
61+
62+
### Accessing the Documentation
63+
64+
Once the API server is running, you can view the documentation and interact with the API by visiting: http://localhost:7777/docs

37-vanilla-agent-custom-features/tests/__init__.py

Whitespace-only changes.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import json
2+
from unittest import mock
3+
from fastapi.testclient import TestClient
4+
from vanilla_agent_custom_features.main import app
5+
import pytest
6+
from pathlib import Path
7+
from openbb_ai.testing import CopilotResponse
8+
9+
test_client = TestClient(app)
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def reset_sse_starlette_appstatus_event():
14+
"""
15+
Fixture that resets the appstatus event in the sse_starlette app.
16+
Should be used on any test that uses sse_starlette to stream events.
17+
"""
18+
# See https://github.com/sysid/sse-starlette/issues/59
19+
from sse_starlette.sse import AppStatus
20+
21+
AppStatus.should_exit_event = None
22+
23+
24+
def test_agents_json():
25+
"""Test that the agents.json endpoint returns the correct configuration."""
26+
response = test_client.get("/agents.json")
27+
assert response.status_code == 200
28+
29+
data = response.json()
30+
assert "vanilla_agent_custom_features" in data
31+
32+
agent_config = data["vanilla_agent_custom_features"]
33+
assert agent_config["name"] == "Vanilla Agent Custom Features"
34+
assert "deep-research" in agent_config["features"]
35+
assert "web-search" in agent_config["features"]
36+
assert not agent_config["features"]["deep-research"]["default"]
37+
assert agent_config["features"]["web-search"]["default"]
38+
39+
40+
def test_query_simple_message():
41+
"""Test basic query with a simple message."""
42+
test_payload_path = (
43+
Path(__file__).parent.parent.parent
44+
/ "testing"
45+
/ "test_payloads"
46+
/ "single_message.json"
47+
)
48+
test_payload = json.load(open(test_payload_path))
49+
50+
response = test_client.post("/v1/query", json=test_payload)
51+
assert response.status_code == 200
52+
copilot_response = CopilotResponse(response.text)
53+
assert copilot_response.has_any("copilotMessage", "2")
54+
55+
56+
def test_query_with_workspace_options():
57+
"""Test query with workspace options to verify feature detection."""
58+
test_payload = {
59+
"messages": [{"role": "human", "content": "Hello, what features are enabled?"}],
60+
"workspace_options": {"deep-research": True, "web-search": False},
61+
}
62+
63+
with mock.patch("openai.AsyncOpenAI") as mock_openai:
64+
# Mock the OpenAI client
65+
mock_client = mock.MagicMock()
66+
mock_openai.return_value = mock_client
67+
68+
# Verify that the system message contains the correct feature status
69+
async def mock_create(**kwargs):
70+
messages = kwargs["messages"]
71+
system_message = messages[0]["content"]
72+
73+
# Check that the system message contains the feature status
74+
assert "Deep Research: ✅ Enabled" in system_message
75+
assert "Web Search: ❌ Disabled" in system_message
76+
77+
# Return a mock stream
78+
class MockEvent:
79+
class Choice:
80+
class Delta:
81+
content = "Hello! Features are configured."
82+
83+
delta = Delta()
84+
85+
choices = [Choice()]
86+
87+
yield MockEvent()
88+
89+
mock_client.chat.completions.create = mock_create
90+
91+
response = test_client.post("/v1/query", json=test_payload)
92+
assert response.status_code == 200
93+
94+
95+
def test_query_default_workspace_options():
96+
"""Test query without workspace options uses defaults."""
97+
test_payload = {"messages": [{"role": "human", "content": "Hi there!"}]}
98+
99+
with mock.patch("openai.AsyncOpenAI") as mock_openai:
100+
# Mock the OpenAI client
101+
mock_client = mock.MagicMock()
102+
mock_openai.return_value = mock_client
103+
104+
# Verify that the system message contains the default feature status
105+
async def mock_create(**kwargs):
106+
messages = kwargs["messages"]
107+
system_message = messages[0]["content"]
108+
109+
# Check that defaults are used (deep-research: False, web-search: True)
110+
assert "Deep Research: ❌ Disabled" in system_message
111+
assert "Web Search: ✅ Enabled" in system_message
112+
113+
# Return a mock stream
114+
class MockEvent:
115+
class Choice:
116+
class Delta:
117+
content = "Hello!"
118+
119+
delta = Delta()
120+
121+
choices = [Choice()]
122+
123+
yield MockEvent()
124+
125+
mock_client.chat.completions.create = mock_create
126+
127+
response = test_client.post("/v1/query", json=test_payload)
128+
assert response.status_code == 200
129+
130+
131+
def test_query_conversation():
132+
"""Test query with multiple messages in conversation."""
133+
test_payload_path = (
134+
Path(__file__).parent.parent.parent
135+
/ "testing"
136+
/ "test_payloads"
137+
/ "multiple_messages.json"
138+
)
139+
test_payload = json.load(open(test_payload_path))
140+
141+
response = test_client.post("/v1/query", json=test_payload)
142+
assert response.status_code == 200
143+
copilot_response = CopilotResponse(response.text)
144+
assert copilot_response.has_any("copilotMessage", "4")
145+
146+
147+
def test_query_no_messages():
148+
"""Test query with empty messages list."""
149+
test_payload = {
150+
"messages": [],
151+
}
152+
response = test_client.post("/v1/query", json=test_payload)
153+
assert "messages list cannot be empty" in response.text

37-vanilla-agent-custom-features/vanilla_agent_custom_features/__init__.py

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from typing import AsyncGenerator
2+
3+
import openai
4+
from fastapi import FastAPI
5+
from fastapi.middleware.cors import CORSMiddleware
6+
from fastapi.responses import JSONResponse
7+
from openai.types.chat import (
8+
ChatCompletionAssistantMessageParam,
9+
ChatCompletionMessageParam,
10+
ChatCompletionSystemMessageParam,
11+
ChatCompletionUserMessageParam,
12+
)
13+
from openbb_ai import message_chunk
14+
from openbb_ai.models import MessageChunkSSE, QueryRequest
15+
from sse_starlette.sse import EventSourceResponse
16+
17+
app = FastAPI()
18+
19+
app.add_middleware(
20+
CORSMiddleware,
21+
allow_origins=["*"],
22+
allow_credentials=True,
23+
allow_methods=["*"],
24+
allow_headers=["*"],
25+
)
26+
27+
28+
@app.get("/agents.json")
29+
def get_copilot_description():
30+
"""Agent descriptor for the OpenBB Workspace."""
31+
return JSONResponse(
32+
content={
33+
"vanilla_agent_custom_features": {
34+
"name": "Vanilla Agent Custom Features",
35+
"description": "A simple agent that reports its feature status.",
36+
"image": (
37+
"https://github.com/OpenBB-finance/copilot-for-terminal-pro/"
38+
"assets/14093308/7da2a512-93b9-478d-90bc-b8c3dd0cabcf"
39+
),
40+
"endpoints": {"query": "/v1/query"},
41+
"features": {
42+
"streaming": True,
43+
"widget-dashboard-select": False,
44+
"widget-dashboard-search": False,
45+
"deep-research": {
46+
"label": "Deep Research",
47+
"default": False,
48+
"description": "Allows the copilot to do deep research",
49+
},
50+
"web-search": {
51+
"label": "Web Search",
52+
"default": True,
53+
"description": "Allows the copilot to search the web.",
54+
},
55+
},
56+
}
57+
}
58+
)
59+
60+
61+
@app.post("/v1/query")
62+
async def query(request: QueryRequest) -> EventSourceResponse:
63+
"""Stream a simple greeting with feature status."""
64+
65+
# Check workspace_options from request payload
66+
# workspace_options is a list like ["web-search"] or ["deep-research", "web-search"]
67+
workspace_options = getattr(request, "workspace_options", [])
68+
69+
# Check which features are enabled
70+
deep_research_enabled = "deep-research" in workspace_options
71+
web_search_enabled = "web-search" in workspace_options
72+
73+
# Build the feature status message
74+
features_msg = (
75+
f"- Deep Research: {'✅ Enabled' if deep_research_enabled else '❌ Disabled'}\n"
76+
f"- Web Search: {'✅ Enabled' if web_search_enabled else '❌ Disabled'}"
77+
)
78+
79+
openai_messages: list[ChatCompletionMessageParam] = [
80+
ChatCompletionSystemMessageParam(
81+
role="system",
82+
content=(
83+
"You are a simple greeting agent.\n"
84+
"Greet the user and let them know their current feature settings:\n"
85+
f"{features_msg}\n"
86+
"Keep your response brief and friendly."
87+
),
88+
)
89+
]
90+
91+
for message in request.messages:
92+
if message.role == "human":
93+
openai_messages.append(
94+
ChatCompletionUserMessageParam(role="user", content=message.content)
95+
)
96+
elif message.role == "ai" and isinstance(message.content, str):
97+
openai_messages.append(
98+
ChatCompletionAssistantMessageParam(
99+
role="assistant", content=message.content
100+
)
101+
)
102+
103+
async def execution_loop() -> AsyncGenerator[MessageChunkSSE, None]:
104+
client = openai.AsyncOpenAI()
105+
async for event in await client.chat.completions.create(
106+
model="gpt-4o",
107+
messages=openai_messages,
108+
stream=True,
109+
):
110+
if chunk := event.choices[0].delta.content:
111+
yield message_chunk(chunk)
112+
113+
return EventSourceResponse(
114+
content=(
115+
event.model_dump(exclude_none=True) async for event in execution_loop()
116+
),
117+
media_type="text/event-stream",
118+
)

0 commit comments

Comments
 (0)