diff --git a/20-financial-prompt-optimizer/README.md b/20-financial-prompt-optimizer/README.md new file mode 100644 index 0000000..d749e94 --- /dev/null +++ b/20-financial-prompt-optimizer/README.md @@ -0,0 +1,60 @@ +# Example agent for financial prompt optimization + +This is a minimal example agent, powered by OpenAI, that optimizes a user's +financial prompt for clarity, specificity, and actionability. It has no widget +integration and focuses solely on improving prompts. + +## Getting started + +Here's how to get your agent up and running: + +### Prerequisites + +Ensure you have poetry, a tool for dependency management and packaging in +Python, as well as your OpenAI API key. + +### Installation and Running + +1. Clone this repository to your local machine. + +2. Install the necessary dependencies: + + ```sh + poetry install --no-root + ``` + +3. Set the OpenAI API key as an environment variable: + + ```sh + export OPENAI_API_KEY= + ``` + +4. Start the API server: + + ```sh + cd financial-prompt-optimizer + poetry run uvicorn main:app --port 7777 --reload + ``` + +This command runs the FastAPI application, making it accessible on your network. + +### Accessing the Documentation + +Once the API server is running, you can view the documentation and interact with +the API by visiting: [http://localhost:7777/docs](http://localhost:7777/docs) + +### Using with OpenBB Workspace (Optional) + +- The agent descriptor is available at: [http://localhost:7777/agents.json](http://localhost:7777/agents.json) +- Features are set to: + - `streaming: true` + - `widget-dashboard-select: false` + - `widget-dashboard-search: false` + +### Expected Behavior + +When you send a user message, the agent streams a single answer containing two +sections: + +- `Optimized Prompt: ` +- `Rationale: <1–3 short bullets on what changed and why>` diff --git a/20-financial-prompt-optimizer/main.py b/20-financial-prompt-optimizer/main.py new file mode 100644 index 0000000..8db81e9 --- /dev/null +++ b/20-financial-prompt-optimizer/main.py @@ -0,0 +1,100 @@ +from typing import AsyncGenerator +import openai + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sse_starlette.sse import EventSourceResponse + +from openbb_ai.models import MessageChunkSSE, QueryRequest +from openbb_ai import message_chunk + +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionUserMessageParam, + ChatCompletionAssistantMessageParam, + ChatCompletionSystemMessageParam, +) + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/agents.json") +def get_copilot_description(): + """Agent descriptor for the OpenBB Workspace.""" + return JSONResponse( + content={ + "financial_prompt_optimizer": { + "name": "Financial Prompt Optimizer", + "description": "Optimizes a user's prompt for finance: clearer, more specific, and actionable.", + "image": "https://github.com/OpenBB-finance/copilot-for-terminal-pro/assets/14093308/7da2a512-93b9-478d-90bc-b8c3dd0cabcf", + "endpoints": {"query": "http://localhost:7777/v1/query"}, + "features": { + "streaming": True, + "widget-dashboard-select": False, + "widget-dashboard-search": False, + }, + } + } + ) + + +@app.post("/v1/query") +async def query(request: QueryRequest) -> EventSourceResponse: + """Stream a concise optimized prompt and rationale.""" + + openai_messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam( + role="system", + content=( + "You are a concise Financial Prompt Optimizer.\n" + "Rewrite the user's prompt to be clearer, more specific, and immediately actionable for financial analysis.\n" + "Always return exactly the improved prompt:\n" + "Optimized Prompt: \n" + ), + ) + ] + + for message in request.messages: + if message.role == "human": + openai_messages.append( + ChatCompletionUserMessageParam(role="user", content=message.content) + ) + elif message.role == "ai" and isinstance(message.content, str): + openai_messages.append( + ChatCompletionAssistantMessageParam( + role="assistant", content=message.content + ) + ) + + async def execution_loop() -> AsyncGenerator[MessageChunkSSE, None]: + client = openai.AsyncOpenAI() + async for event in await client.chat.completions.create( + model="gpt-4o", + messages=openai_messages, + stream=True, + ): + if chunk := event.choices[0].delta.content: + yield message_chunk(chunk) + + return EventSourceResponse( + content=( + event.model_dump(exclude_none=True) async for event in execution_loop() + ), + media_type="text/event-stream", + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=7777, reload=True) diff --git a/30-vanilla-agent-raw-widget-data/vanilla_agent_raw_context/main.py b/30-vanilla-agent-raw-widget-data/vanilla_agent_raw_context/main.py index 3300265..28ff790 100644 --- a/30-vanilla-agent-raw-widget-data/vanilla_agent_raw_context/main.py +++ b/30-vanilla-agent-raw-widget-data/vanilla_agent_raw_context/main.py @@ -60,6 +60,10 @@ async def query(request: QueryRequest) -> EventSourceResponse: and request.widgets.primary ): widget_requests: list[WidgetRequest] = [] + # Note: If we wanted to iterate through the widgets on the dashboard + # rather than on the widgets on the explicit context + # then we would need to iterate through request.widgets.secondary + # and the agents.json would need "widget-dashboard-search": True for widget in request.widgets.primary: widget_requests.append( WidgetRequest( diff --git a/31-vanilla-agent-reasoning-steps/vanilla_agent_reasoning_steps/main.py b/31-vanilla-agent-reasoning-steps/vanilla_agent_reasoning_steps/main.py index 53d00d9..0a1c2a3 100644 --- a/31-vanilla-agent-reasoning-steps/vanilla_agent_reasoning_steps/main.py +++ b/31-vanilla-agent-reasoning-steps/vanilla_agent_reasoning_steps/main.py @@ -21,7 +21,7 @@ app.add_middleware( CORSMiddleware, - allow_origins=["https://pro.openbb.co"], + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -101,13 +101,6 @@ async def execution_loop() -> AsyncGenerator[MessageChunkSSE, None]: if chunk := event.choices[0].delta.content: yield message_chunk(chunk).model_dump() - # Reasoning steps can be yielded from anywhere, as long as they are - # yielded from the execution loop back to the OpenBB Workspace. - yield reasoning_step( - event_type="INFO", - message="Answering complete!", - ).model_dump() - return EventSourceResponse( content=execution_loop(), media_type="text/event-stream", diff --git a/36-vanilla-agent-pdf-citations/README.md b/36-vanilla-agent-pdf-citations/README.md new file mode 100644 index 0000000..a93d786 --- /dev/null +++ b/36-vanilla-agent-pdf-citations/README.md @@ -0,0 +1,57 @@ +# Example agent that can handle PDF data with citations + +This is an example agent, powered by OpenAI, that can perform question answering +using data retrieved from widgets on OpenBB Workspace. It specifically demonstrates +how to handle PDF data with citation support, including highlighting specific text passages. + +## Getting started + +Here's how to get your agent up and running: + +### Prerequisites + +Ensure you have poetry, a tool for dependency management and packaging in +Python, as well as your OpenAI API key. + +### Installation and Running + +1. Clone this repository to your local machine. + +2. Set the OpenAI API key as an environment variable in your .bashrc or .zshrc file: + + ``` sh + # in .zshrc or .bashrc + export OPENAI_API_KEY= + ``` + +3. Install the necessary dependencies: + +``` sh +poetry install --no-root +``` + +4.Start the API server: + +``` sh +cd 36-vanilla-pdf-citations +poetry run uvicorn vanilla_agent_pdf_citations.main:app --port 7777 --reload +``` + +This command runs the FastAPI application, making it accessible on your network. + +### Testing the Agent + +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. + +You can run the tests with: + +```sh +pytest tests +``` + +### Accessing the Documentation + +Once the API server is running, you can view the documentation and interact with +the API by visiting: http://localhost:7777/docs diff --git a/36-vanilla-agent-pdf-citations/tests/__init__.py b/36-vanilla-agent-pdf-citations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/36-vanilla-agent-pdf-citations/tests/test_agent.py b/36-vanilla-agent-pdf-citations/tests/test_agent.py new file mode 100644 index 0000000..a3fb6e4 --- /dev/null +++ b/36-vanilla-agent-pdf-citations/tests/test_agent.py @@ -0,0 +1,85 @@ +import json +from unittest import mock +from fastapi.testclient import TestClient +from vanilla_agent_pdf.main import app +import pytest +from pathlib import Path +from openbb_ai.testing import CopilotResponse + +test_client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_sse_starlette_appstatus_event(): + """ + Fixture that resets the appstatus event in the sse_starlette app. + Should be used on any test that uses sse_starlette to stream events. + """ + # See https://github.com/sysid/sse-starlette/issues/59 + from sse_starlette.sse import AppStatus + + AppStatus.should_exit_event = None + + +def test_query(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "single_message.json" + ) + test_payload = json.load(open(test_payload_path)) + + response = test_client.post("/v1/query", json=test_payload) + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + (copilot_response.has_any("copilotMessage", "2")) + + +def test_query_conversation(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "multiple_messages.json" + ) + test_payload = json.load(open(test_payload_path)) + + response = test_client.post("/v1/query", json=test_payload) + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + (copilot_response.has_any("copilotMessage", "4")) + + +def test_query_no_messages(): + test_payload = { + "messages": [], + } + response = test_client.post("/v1/query", json=test_payload) + "messages list cannot be empty" in response.text + + +def test_query_completes_remote_function_call_with_pdf_url(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "message_with_primary_widget_and_tool_call_pdf_url.json" + ) + test_payload = json.load(open(test_payload_path)) + + with open( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "openbb_story.pdf", + "rb", + ) as pdf: + pdf_content = pdf.read() + + with mock.patch("vanilla_agent_pdf.main._download_file", return_value=pdf_content): + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + (copilot_response.starts("copilotMessage").with_("Didier Lopes").with_("Gamestonk")) diff --git a/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/__init__.py b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py new file mode 100644 index 0000000..03dfd6a --- /dev/null +++ b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py @@ -0,0 +1,312 @@ +import base64 +import io +import logging +from typing import AsyncGenerator, List, Tuple, Dict, Any + +import httpx +import openai +import pdfplumber +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) +from openbb_ai import cite, citations, get_widget_data, message_chunk +from openbb_ai.models import ( + Citation, + CitationCollectionSSE, + CitationHighlightBoundingBox, + DataContent, + DataFileReferences, + FunctionCallSSE, + MessageChunkSSE, + PdfDataFormat, + QueryRequest, + SingleDataContent, + SingleFileReference, + WidgetRequest, +) +from sse_starlette.sse import EventSourceResponse + +logger = logging.getLogger(__name__) + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/agents.json") +def get_copilot_description(): + """Agents configuration file for the OpenBB Workspace""" + return JSONResponse( + content={ + "vanilla_agent_pdf_citations": { + "name": "Vanilla Agent PDF Citations", + "description": "A vanilla agent that handles PDF data with citation support.", + "image": "https://github.com/OpenBB-finance/copilot-for-terminal-pro/assets/14093308/7da2a512-93b9-478d-90bc-b8c3dd0cabcf", + "endpoints": {"query": "http://localhost:7777/v1/query"}, + "features": { + "streaming": True, + "widget-dashboard-select": True, + "widget-dashboard-search": False, + }, + } + } + ) + + +@app.post("/v1/query") +async def query(request: QueryRequest) -> EventSourceResponse: + """Query the Copilot.""" + + # We only automatically fetch widget data if the last message is from a + # human, and widgets have been explicitly added to the request. + if ( + request.messages[-1].role == "human" + and request.widgets + and request.widgets.primary + ): + widget_requests: list[WidgetRequest] = [] + for widget in request.widgets.primary: + widget_requests.append( + WidgetRequest( + widget=widget, + input_arguments={ + param.name: param.current_value for param in widget.params + }, + ) + ) + + async def retrieve_widget_data() -> AsyncGenerator[FunctionCallSSE, None]: + yield get_widget_data(widget_requests) + + # Early exit to retrieve widget data + return EventSourceResponse( + content=(event.model_dump() async for event in retrieve_widget_data()), + media_type="text/event-stream", + ) + + # Format the messages into a list of OpenAI messages + openai_messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam( + role="system", + content="You are a helpful financial assistant. Your name is 'Vanilla Agent PDF Citations'.", + ) + ] + + context_str = "" + citations_list: list[Citation] = [] + pdf_text_positions: List[ + Dict[str, Any] + ] = [] # Store PDF text positions for citation + for index, message in enumerate(request.messages): + if message.role == "human": + openai_messages.append( + ChatCompletionUserMessageParam(role="user", content=message.content) + ) + elif message.role == "ai": + if isinstance(message.content, str): + openai_messages.append( + ChatCompletionAssistantMessageParam( + role="assistant", content=message.content + ) + ) + # We only add the most recent tool call / widget data to context. We do + # this **only for this particular example** to prevent + # previously-retrieved widget data from piling up and exceeding the + # context limit of the LLM. + elif message.role == "tool" and index == len(request.messages) - 1: + widget_text, positions = await handle_widget_data(message.data) + context_str += widget_text + pdf_text_positions.extend(positions) + + # Create citations for the widget data + for widget_data_request in message.input_arguments["data_sources"]: + # Find matching widget + matching_widgets = [ + w + for w in request.widgets.primary + if str(w.uuid) == widget_data_request["widget_uuid"] + ] + + if not matching_widgets: + continue + + widget = matching_widgets[0] + + # Citation 1: Basic widget citation + basic_citation = cite( + widget=widget, + input_arguments=widget_data_request["input_args"], + ) + citations_list.append(basic_citation) + + # Citation 2: Highlight first sentence from PDF + # This is how you reference a specific sentence + if pdf_text_positions and len(pdf_text_positions) > 0: + first_line = pdf_text_positions[0] + + pdf_citation = cite( + widget=widget, + input_arguments=widget_data_request["input_args"], + extra_details={ + "Page": first_line["page"], + "Reference": "First sentence of document", + }, + ) + + # Add highlighting for the first sentence + pdf_citation.quote_bounding_boxes = [ + [ + CitationHighlightBoundingBox( + text=first_line["text"][:100], + page=first_line["page"], + x0=first_line["x0"], + top=first_line["top"], + x1=first_line["x1"], + bottom=first_line["bottom"], + ) + ] + ] + + citations_list.append(pdf_citation) + + if context_str: + openai_messages[-1]["content"] += "\n\n" + context_str # type: ignore + + # Define the execution loop. + async def execution_loop() -> ( + AsyncGenerator[MessageChunkSSE | CitationCollectionSSE, None] + ): + client = openai.AsyncOpenAI() + async for event in await client.chat.completions.create( + model="gpt-4o", + messages=openai_messages, + stream=True, + ): + if chunk := event.choices[0].delta.content: + yield message_chunk(chunk) + + if citations_list: + yield citations(citations_list) + + # Stream the SSEs back to the client. + return EventSourceResponse( + content=(event.model_dump() async for event in execution_loop()), + media_type="text/event-stream", + ) + + +async def _download_file(url: str) -> bytes: + """Download file from URL.""" + logger.info("Downloading file from %s", url) + async with httpx.AsyncClient() as client: + file_content = await client.get(url) + return file_content.content + + +def extract_pdf_with_positions(pdf_bytes: bytes) -> Tuple[str, List[Dict[str, Any]]]: + """Extract text and positions from PDF.""" + document_text = "" + text_positions = [] + + with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf: + for page_num, page in enumerate(pdf.pages, 1): + # Extract text with character-level data for accurate positioning + if page.chars: + # Group characters into lines + lines = {} + for char in page.chars: + y = round(char["top"]) # Round to group by line + if y not in lines: + lines[y] = {"chars": [], "x0": char["x0"], "x1": char["x1"]} + lines[y]["chars"].append(char["text"]) + lines[y]["x0"] = min(lines[y]["x0"], char["x0"]) + lines[y]["x1"] = max(lines[y]["x1"], char["x1"]) + + # Get first non-empty line for citation + sorted_lines = sorted(lines.items()) + for y_pos, line_data in sorted_lines[:5]: # Check first 5 lines + line_text = "".join(line_data["chars"]).strip() + if line_text and len(line_text) > 10: # Skip very short lines + text_positions.append( + { + "text": line_text, + "page": page_num, + "x0": line_data["x0"], + "top": y_pos, + "x1": line_data["x1"], + "bottom": y_pos + 12, # Standard line height + } + ) + break # Just get the first meaningful line + + # Also extract full text for context + page_text = page.extract_text() + if page_text: + document_text += page_text + "\n\n" + + return document_text, text_positions + + +# Files can either be served from a URL... +async def _get_url_pdf_text( + data: SingleFileReference, +) -> Tuple[str, List[Dict[str, Any]]]: + file_content = await _download_file(str(data.url)) + return extract_pdf_with_positions(file_content) + + +# ... or via base64 encoding. +async def _get_base64_pdf_text( + data: SingleDataContent, +) -> Tuple[str, List[Dict[str, Any]]]: + file_content = base64.b64decode(data.content) + return extract_pdf_with_positions(file_content) + + +async def handle_widget_data( + data: list[DataContent | DataFileReferences], +) -> Tuple[str, List[Dict[str, Any]]]: + """Process widget data and extract PDF text with positions if applicable. + + Returns: + Tuple containing: + - result_str: Formatted text content from all data sources + - all_positions: Text position data for PDF citations (empty for non-PDFs) + """ + result_str = "--- Data ---\n" + all_positions = [] + + for result in data: + for item in result.items: + if isinstance(item.data_format, PdfDataFormat): + result_str += f"===== {item.data_format.filename} =====\n" + if isinstance(item, SingleDataContent): + # Handle the base64 PDF case. + text, positions = await _get_base64_pdf_text(item) + result_str += text + all_positions.extend(positions) + elif isinstance(item, SingleFileReference): + # Handle the URL PDF case. + text, positions = await _get_url_pdf_text(item) + result_str += text + all_positions.extend(positions) + else: + # Handle other data formats by just dumping the content as a + # string. + result_str += f"{item.content}\n" + result_str += "------\n" + + return result_str, all_positions diff --git a/40-vanilla-agent-dashboard-widgets/README.md b/40-vanilla-agent-dashboard-widgets/README.md new file mode 100644 index 0000000..2d262f9 --- /dev/null +++ b/40-vanilla-agent-dashboard-widgets/README.md @@ -0,0 +1,24 @@ +# 40 - Vanilla Agent Dashboard Widgets + +This example demonstrates a simple agent that lists all widgets available on the current dashboard, showing their metadata and parameters in a structured format. + +Key behaviors: + +- Exposes `agents.json` with `widget-dashboard-search` enabled so the Workspace sends dashboard widget metadata (as `widgets.secondary`, etc.). +- Lists all widgets from both explicit context (primary) and dashboard context (secondary). +- Shows detailed widget information including: + - Widget metadata (name, description, ID, category, UUID) + - Parameters table with type, default, current values, options, and descriptions +- Organizes dashboard widgets by tabs for better visibility. +- Does NOT automatically fetch widget data - focuses purely on widget discovery and listing. + +## Run locally + +- Install dependencies at repo root: `poetry install --no-root` +- Start the API from this directory: + + `poetry run uvicorn vanilla_agent_dashboard_widgets.main:app --port 7777 --reload` + +## Test + +- From this directory: `poetry run pytest tests` diff --git a/40-vanilla-agent-dashboard-widgets/tests/__init__.py b/40-vanilla-agent-dashboard-widgets/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/40-vanilla-agent-dashboard-widgets/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/40-vanilla-agent-dashboard-widgets/tests/test_agent.py b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py new file mode 100644 index 0000000..a327b51 --- /dev/null +++ b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py @@ -0,0 +1,117 @@ +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from vanilla_agent_dashboard_widgets.main import app +from openbb_ai.testing import CopilotResponse + + +test_client = TestClient(app) + + +@pytest.fixture(autouse=True) +def reset_sse_starlette_appstatus_event(): + """ + Fixture that resets the appstatus event in the sse_starlette app. + Should be used on any test that uses sse_starlette to stream events. + """ + # See https://github.com/sysid/sse-starlette/issues/59 + from sse_starlette.sse import AppStatus + + AppStatus.should_exit_event = None + + +def test_agents_json_has_dashboard_search_feature_enabled(): + response = test_client.get("/agents.json") + assert response.status_code == 200 + data = response.json() + agent = data.get("vanilla_agent_dashboard_widgets") + assert agent is not None + assert agent["features"]["widget-dashboard-search"] is True + + +def test_query_lists_dashboard_widgets_from_secondary(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "retrieve_widget_from_dashboard.json" + ) + payload = json.load(open(test_payload_path)) + + # Simulate no explicit primary selection + payload["widgets"]["primary"] = [] + # Ask to list widgets + payload["messages"][0]["content"] = "What widgets are available in the dashboard?" + + response = test_client.post("/v1/query", json=payload) + assert response.status_code == 200 + + # We expect a message listing dashboard widgets (secondary only) + CopilotResponse(response.text).has_any("copilotMessage", "Company News") + # Should show dashboard context header + CopilotResponse(response.text).has_any("copilotMessage", "Dashboard Context") + + +def test_query_lists_primary_widgets_when_selected(): + # Use same payload but keep primary set + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "retrieve_widget_from_dashboard.json" + ) + payload = json.load(open(test_payload_path)) + + response = test_client.post("/v1/query", json=payload) + assert response.status_code == 200 + + # We expect a message listing widgets including primary context + CopilotResponse(response.text).has_any("copilotMessage", "Explicit Context") + # Should also show dashboard context if available + CopilotResponse(response.text).has_any("copilotMessage", "Dashboard Context") + + +def test_query_shows_widget_metadata_and_parameters(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "retrieve_widget_from_dashboard.json" + ) + payload = json.load(open(test_payload_path)) + payload["messages"][0]["content"] = "Show me widget details" + + response = test_client.post("/v1/query", json=payload) + assert response.status_code == 200 + + # Should show widget metadata fields + CopilotResponse(response.text).has_any("copilotMessage", "Description") + CopilotResponse(response.text).has_any("copilotMessage", "UUID") + # Should show parameters table + CopilotResponse(response.text).has_any("copilotMessage", "Parameters") + + +def test_query_does_not_fetch_widget_data(): + """Test that the agent only lists widgets and does not fetch data""" + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "retrieve_widget_from_dashboard.json" + ) + payload = json.load(open(test_payload_path)) + payload["messages"][0]["content"] = "Hello" + + response = test_client.post("/v1/query", json=payload) + assert response.status_code == 200 + + # Should NOT have any function calls for get_widget_data + response_text = response.text + assert "get_widget_data" not in response_text + assert "copilotFunctionCall" not in response_text + + # Should only have messages listing widgets + CopilotResponse(response.text).has_any("copilotMessage") diff --git a/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py new file mode 100644 index 0000000..1b7902e --- /dev/null +++ b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -0,0 +1,168 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sse_starlette.sse import EventSourceResponse + +from openbb_ai.models import QueryRequest +from openbb_ai import message_chunk + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/agents.json") +def get_copilot_description(): + """Copilot descriptor for OpenBB Workspace.""" + return JSONResponse( + content={ + "vanilla_agent_dashboard_widgets": { + "name": "Vanilla Agent Dashboard Widgets", + "description": "Lists dashboard widgets and retrieves data from the first widget on the dashboard (if any).", + "image": "https://github.com/OpenBB-finance/copilot-for-terminal-pro/assets/14093308/7da2a512-93b9-478d-90bc-b8c3dd0cabcf", + "endpoints": {"query": "http://localhost:7777/v1/query"}, + "features": { + "streaming": True, + "widget-dashboard-select": True, + "widget-dashboard-search": True, + }, + } + } + ) + + +@app.post("/v1/query") +async def query(request: QueryRequest) -> EventSourceResponse: + """Query the Copilot.""" + + # Get all widgets from dashboard (used in multiple places) + all_widgets = [] + if request.widgets: + # These are the widgets that are available in the explicit context tab + if request.widgets.primary: + all_widgets.extend(request.widgets.primary) + # These are the widgets that are available in the dashboard + if request.widgets.secondary: + all_widgets.extend(request.widgets.secondary) + + # Helper function to format widget details + def format_widget(w): + msg = f"### {w.name or w.widget_id or 'Unnamed Widget'}\n\n" + msg += "| Field | Value |\n" + msg += "|-------|-------|\n" + msg += f"| Name | {w.name or 'N/A'} |\n" + # Clean up description - replace newlines with spaces + description = str(w.description or "N/A") + description = description.replace("\n", " ").replace(" ", " ").strip() + if len(description) > 150: + description = description[:147] + "..." + msg += f"| Description | {description} |\n" + msg += f"| ID | {w.widget_id or 'N/A'} |\n" + msg += f"| Category | {getattr(w, 'category', 'N/A') or 'N/A'} |\n" + msg += f"| UUID | {getattr(w, 'uuid', 'N/A') or 'N/A'} |\n\n" + + # Parameters table + if w.params: + msg += f"#### {w.name or w.widget_id or 'Unnamed Widget'} Parameters\n\n" + msg += "| Parameter | Type | Default | Current | Options | Description |\n" + msg += "|-----------|------|---------|---------|---------|-------------|\n" + for p in w.params: + param_type = str(getattr(p, "type", "N/A") or "N/A") + default_val = str(getattr(p, "default_value", "N/A") or "N/A") + current_val = str(getattr(p, "current_value", "N/A") or "N/A") + # Clean up description - replace newlines with spaces + param_desc = str(getattr(p, "description", "N/A") or "N/A") + param_desc = param_desc.replace("\n", " ").replace(" ", " ").strip() + # Truncate long descriptions + if len(param_desc) > 100: + param_desc = param_desc[:97] + "..." + # Handle possible options + options = getattr(p, "options", None) + options_str = ", ".join(str(o) for o in options) if options else "" + # Truncate long options list + if len(options_str) > 50: + options_str = options_str[:47] + "..." + msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" + msg += "\n" + return msg + + widget_list_msg = "" + + # Explicit Context (Primary) - widgets explicitly selected + if request.widgets and request.widgets.primary: + widget_list_msg += "# Explicit Context (Primary)\n\n" + for w in request.widgets.primary: + widget_list_msg += format_widget(w) + widget_list_msg += "\n" + + # Dashboard Context (Secondary) - widgets from current dashboard + # Although all widgets are already in all_widgets, we want to show them grouped by tab + # and for that we need to use the workspace_state.current_dashboard_info + if request.workspace_state and request.workspace_state.current_dashboard_info: + tabs = request.workspace_state.current_dashboard_info.tabs + active_tab = request.workspace_state.current_dashboard_info.current_tab_id + + if tabs: + widget_list_msg += "# Dashboard Context (Secondary)\n\n" + if active_tab: + widget_list_msg += f"Active tab: {active_tab}\n\n" + + for tab in tabs: + widget_list_msg += f"## Tab: {tab.tab_id}\n\n" + if tab.widgets: + for widget in tab.widgets: + # Look up full widget details from all_widgets + full_widget = None + for w in all_widgets: + if str(getattr(w, "uuid", "")) == str(widget.widget_uuid): + full_widget = w + break + + if full_widget: + widget_list_msg += format_widget(full_widget) + else: + # Widget not found in all_widgets, show basic info + widget_list_msg += f"### {widget.name}\n\n" + widget_list_msg += "| Field | Value |\n" + widget_list_msg += "|-------|-------|\n" + widget_list_msg += f"| Name | {widget.name or 'N/A'} |\n" + widget_list_msg += ( + f"| UUID | {widget.widget_uuid or 'N/A'} |\n\n" + ) + else: + widget_list_msg += " (no widgets)\n" + widget_list_msg += "\n" + elif all_widgets and not (request.widgets and request.widgets.primary): + # Fallback if no primary widgets shown above + widget_list_msg += "**Available widgets**\n\n" + for w in all_widgets: + widget_list_msg += format_widget(w) + widget_list_msg += "\n" + + # Always show widget list on human messages + if request.messages[-1].role == "human": + # Stream widget list only + async def show_widgets(): + if widget_list_msg: + yield message_chunk(widget_list_msg.rstrip()).model_dump() + else: + yield message_chunk("No widgets found on your dashboard.").model_dump() + + # Return early with widget info only + return EventSourceResponse( + content=show_widgets(), + media_type="text/event-stream", + ) + + # If we reach here, no specific handler matched - return empty response + async def empty_response(): + yield message_chunk("No action taken.").model_dump() + + return EventSourceResponse(content=empty_response(), media_type="text/event-stream") diff --git a/README.md b/README.md index ca76a0d..fefbf04 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,50 @@ Welcome to the example repository for integrating custom agents into the OpenBB Workspace. -This repository provides everything you need to build and add your own custom -agents that are compatible with the OpenBB Workspace. +This repository provides everything you need to build and add your own custom agents that are compatible with the OpenBB Workspace. It depends heavily on the [OpenBB AI SDK](https://github.com/OpenBB-finance/openbb-ai). For documentation on how to use the OpenBB AI SDK (highly recommended!), see the [OpenBB AI SDK README](https://github.com/OpenBB-finance/openbb-ai). ## Examples -If you prefer diving straight into code, we have a growing list of examples of -custom agents in this repository, varying in complexity and features: +If you prefer diving straight into code, we have a growing list of examples of custom agents in this repository, varying in complexity and features: + +- [A vanilla agent that can improve a user prompt](./financial-prompt-optimizer) + +CleanShot 2025-09-14 at 17 35 36@2x + - [A vanilla agent that retrieves raw widget data](./30-vanilla-agent-raw-widget-data) + +30-raw-reply-with-context + - [A vanilla agent that yields reasoning steps to OpenBB Workspace](./31-vanilla-agent-reasoning-steps) + +31-reasoning + - [A vanilla agent that can retrieve data from OpenBB Workspace and produce citations](./32-vanilla-agent-raw-widget-data-citations) + +32-citations + - [A vanilla agent that can produce charts](./33-vanilla-agent-charts) + +33-charts + - [A vanilla agent that can produce tables](./34-vanilla-agent-tables) + +34-tables + - [A vanilla agent that can handle PDF data](./35-vanilla-agent-pdf) -These examples are a good starting point for building your own custom agent if -you are interested in a specific feature or use case. +35-pdf + +- [A vanilla agent that can handle PDF data and add citations in the document](./36-vanilla-agent-pdf-citations) + +CleanShot 2025-09-17 at 17 10 39@2x + +- [A vanilla agent that can access widgets on the dashboard](./36-vanilla-agent-dashboard-widgets) + +CleanShot 2025-09-14 at 17 06 14@2x + +These examples are a good starting point for building your own custom agent if you are interested in a specific feature or use case.