From 8268a7bc306489e9dd447bf8ee06501d4da684cb Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 11:45:21 -0400 Subject: [PATCH 01/29] add vanilla agent dashboard --- 36-vanilla-agent-dashboard-widgets/README.md | 21 ++ .../tests/__init__.py | 1 + .../tests/test_agent.py | 90 +++++ .../__init__.py | 1 + .../vanilla_agent_dashboard_widgets/main.py | 344 ++++++++++++++++++ financial-prompt-optimizer/README.md | 62 ++++ financial-prompt-optimizer/main.py | 100 +++++ 7 files changed, 619 insertions(+) create mode 100644 36-vanilla-agent-dashboard-widgets/README.md create mode 100644 36-vanilla-agent-dashboard-widgets/tests/__init__.py create mode 100644 36-vanilla-agent-dashboard-widgets/tests/test_agent.py create mode 100644 36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py create mode 100644 36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py create mode 100644 financial-prompt-optimizer/README.md create mode 100644 financial-prompt-optimizer/main.py diff --git a/36-vanilla-agent-dashboard-widgets/README.md b/36-vanilla-agent-dashboard-widgets/README.md new file mode 100644 index 0000000..383e8b3 --- /dev/null +++ b/36-vanilla-agent-dashboard-widgets/README.md @@ -0,0 +1,21 @@ +# 36 - Vanilla Agent Dashboard Widgets + +This example demonstrates a simple agent that receives the full list of widgets present on the current dashboard and passes that context directly to the LLM. The model decides which widget(s) to use and issues a function call accordingly. + +Key behaviors: + +- Exposes `agents.json` with `widget-dashboard-search` enabled so the Workspace sends dashboard widget metadata (as `widgets.secondary`, etc.). +- If the user has selected primary widgets, the agent immediately issues a function call to fetch data for them. +- Otherwise, the agent does not select widgets heuristically. It appends the full dashboard widget list to the prompt and instructs the LLM to respond with a `get_widget_data` JSON function call when needed. +- Falls back to a plain LLM reply if no data is needed. + +## 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/36-vanilla-agent-dashboard-widgets/tests/__init__.py b/36-vanilla-agent-dashboard-widgets/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/36-vanilla-agent-dashboard-widgets/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/36-vanilla-agent-dashboard-widgets/tests/test_agent.py b/36-vanilla-agent-dashboard-widgets/tests/test_agent.py new file mode 100644 index 0000000..676a7d4 --- /dev/null +++ b/36-vanilla-agent-dashboard-widgets/tests/test_agent.py @@ -0,0 +1,90 @@ +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(): + 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_recognizes_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 instead of retrieving; ensures non-LLM path is exercised + 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 regular message listing dashboard widgets + CopilotResponse(response.text).has_any("copilotMessage", "Stock Price") + + +def test_query_respects_primary_selection_and_calls_get_widget_data(): + # 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 function call – let the UI handle the actual retrieval + CopilotResponse(response.text).starts("copilotFunctionCall").with_( + {"function": "get_widget_data"} + ) + + +def test_query_lists_dashboard_widgets(): + # Ask to list widgets and expect a direct message with names + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "retrieve_widget_from_dashboard.json" + ) + payload = json.load(open(test_payload_path)) + payload["widgets"]["primary"] = [] + payload["messages"][0]["content"] = "What widgets are available in the dashboard?" + + response = test_client.post("/v1/query", json=payload) + assert response.status_code == 200 + + CopilotResponse(response.text).has_any("copilotMessage", "Stock Price") diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py new file mode 100644 index 0000000..ddba95c --- /dev/null +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from typing import AsyncGenerator +import json +import re + +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 import WidgetRequest, get_widget_data, message_chunk +from openbb_ai.models import MessageChunkSSE, QueryRequest, FunctionCallSSE + +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) + + +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": "Passes all dashboard widgets to the LLM; the model selects widgets and issues function calls when needed.", + "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: + """Stream either a function call or an AI answer.""" + if not request.messages: + return JSONResponse( + status_code=400, content={"detail": "messages list cannot be empty"} + ) # type: ignore[return-value] + + last_message = request.messages[-1] + + # 1) If the user is asking to list dashboard widgets, answer directly + def _is_widgets_list_request(text: str | None) -> bool: + if not text: + return False + q = text.lower() + if "dashboard" in q and "widget" in q: + triggers = ["what", "which", "list", "show", "available"] + return any(t in q for t in triggers) + return False + + if last_message.role == "human" and _is_widgets_list_request(last_message.content): + primary = (request.widgets.primary if request.widgets else None) or [] + secondary = (request.widgets.secondary if request.widgets else None) or [] + # Prefer the full dashboard list (secondary). Include selected if any. + secondary_names = [w.name or w.widget_id or "Unnamed Widget" for w in secondary] + primary_names = [w.name or w.widget_id or "Unnamed Widget" for w in primary] + + lines: list[str] = [] + if secondary_names: + lines.append("Widgets on your current dashboard:") + for nm in secondary_names: + lines.append(f"- {nm}") + if primary_names: + lines.append("") + lines.append("Currently selected widgets:") + for nm in primary_names: + lines.append(f"- {nm}") + + text = ( + "\n".join(lines) + if lines + else "I couldn't detect any widgets in your dashboard payload." + ) + + async def list_widgets_events(): + # Single, clean message without a leading empty chunk + yield message_chunk(text) + + return EventSourceResponse( + content=( + event.model_dump(exclude_none=True) + async for event in list_widgets_events() + ), + media_type="text/event-stream", + ) + + # 3) Auto-fetch data for explicitly selected primary widgets + if last_message.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_primary(): + yield get_widget_data(widget_requests) + + return EventSourceResponse( + content=( + event.model_dump(exclude_none=True) + async for event in retrieve_widget_data_primary() + ), + media_type="text/event-stream", + ) + + # Fallback to plain LLM response + openai_messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam( + role="system", + content=( + "You are a helpful financial assistant named 'Vanilla Agent'.\n" + "You have access to a list of available dashboard widgets.\n" + "When you need data, respond ONLY with a JSON object using this exact schema (no extra text):\n" + "{\n" + ' "function": "get_widget_data",\n' + ' "input_arguments": {\n' + ' "data_sources": [{\n' + ' "widget_uuid": "",\n' + ' "origin": "",\n' + ' "id": "",\n' + ' "input_args": { "": "" }\n' + " }]\n" + " }\n" + "}\n" + "Only choose widgets from the provided list. If no data is needed, answer normally." + ), + ) + ] + + context_str = "" + 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 + ) + ) + elif message.role == "tool": + # Only use the most recent tool result to avoid context bloat + if index == len(request.messages) - 1: + context_str += "Use the following data to answer the question:\n\n" + result_str = "--- Data ---\n" + try: + for result in message.data: + for item in result.items: + # Prefer 'content' if present; otherwise include URL reference + if getattr(item, "content", None): + result_str += f"{item.content}\n" + elif getattr(item, "url", None): + filename = getattr( + getattr(item, "data_format", None), "filename", None + ) + if filename: + result_str += ( + f"File available: {filename} ({item.url})\n" + ) + else: + result_str += f"File available at: {item.url}\n" + result_str += "------\n" + except Exception: + # If schema differs, safely ignore rather than failing + pass + context_str += result_str + + # If dashboard widgets are available, append them to context for the model to choose from. + if request.widgets and ( + request.widgets.primary or request.widgets.secondary or request.widgets.extra + ): + lines: list[str] = [ + "Available dashboard widgets (choose from these if needed):\n" + ] + for bucket_name, bucket in ( + ("primary", request.widgets.primary or []), + ("secondary", request.widgets.secondary or []), + ("extra", request.widgets.extra or []), + ): + if not bucket: + continue + lines.append(f"[{bucket_name}]\n") + for w in bucket: + # Render params: name + current_value snapshot + param_pairs = [ + f"{p.name}={(p.current_value if hasattr(p, 'current_value') else None)}" + for p in w.params + ] + params_str = ", ".join(param_pairs) + lines.append( + "- uuid=" + + str(getattr(w, "uuid", "")) + + ", id=" + + (w.widget_id or "") + + ", origin=" + + (w.origin or "") + + ", name=" + + (w.name or "") + + (f", params: {params_str}" if params_str else "") + ) + lines.append("") + if lines: + context_str += "\n" + "\n".join(lines) + + # If we have context from the latest tool message, append it to the last user/assistant message + if context_str and len(openai_messages) > 1: + try: + openai_messages[-1]["content"] += "\n\n" + context_str # type: ignore[index] + except Exception: + # If last message isn't content-bearing, attach to system as fallback + openai_messages[0]["content"] += "\n\n" + context_str # type: ignore[index] + + def _strip_code_fences(text: str) -> str: + # Remove ```json ... ``` or ``` ... ``` fences if present + fence_match = re.search(r"```(?:json|JSON)?\s*([\s\S]*?)\s*```", text) + if fence_match: + return fence_match.group(1).strip() + return text.strip() + + def _extract_json_object(text: str) -> dict | None: + # Try direct parse, then strip common code fences and retry. + for candidate in (text, _strip_code_fences(text)): + try: + obj = json.loads(candidate) + if isinstance(obj, dict): + return obj + except Exception: + continue + return None + + async def execution_loop() -> ( + AsyncGenerator[MessageChunkSSE | FunctionCallSSE, None] + ): + client = openai.AsyncOpenAI() + + stream = await client.chat.completions.create( + model="gpt-4o", + messages=openai_messages, + stream=True, + ) + + full_text = "" + async for event in stream: + if chunk := event.choices[0].delta.content: + full_text += chunk + + # Try to interpret the model output as a function call request + try: + data = _extract_json_object(full_text) + # Accept both `get_widget_data` and escaped variants (defensive) + func = (data or {}).get("function") if isinstance(data, dict) else None + if ( + isinstance(data, dict) + and func + and func.replace("\\_", "_") == "get_widget_data" + ): + # Build WidgetRequest list from declared data_sources by resolving + # widget UUIDs against the provided widgets in the request + ds_list = data.get("input_arguments", {}).get("data_sources", []) or [] + # Flatten available widgets for lookup + all_widgets = [] + if request.widgets: + for col in [ + request.widgets.primary or [], + request.widgets.secondary or [], + request.widgets.extra or [], + ]: + all_widgets.extend(col) + + def find_widget(uuid: str | None, wid: str | None): + if uuid: + for w in all_widgets: + if str(getattr(w, "uuid", "")) == str(uuid): + return w + if wid: + for w in all_widgets: + if (w.widget_id or "") == wid: + return w + return None + + widget_requests: list[WidgetRequest] = [] + for ds in ds_list: + w_uuid = ds.get("widget_uuid") + w_id = ds.get("id") + widget = find_widget(w_uuid, w_id) + if not widget: + continue + input_args = ds.get("input_args") or { + p.name: p.current_value for p in widget.params + } + widget_requests.append( + WidgetRequest(widget=widget, input_arguments=input_args) + ) + + if widget_requests: + # Emit a typed function call SSE so the UI retrieves data + yield get_widget_data(widget_requests) + return + except Exception: + # Not a function call; continue to output accumulated text + pass + + # If no function call was detected, stream the final accumulated text (single message) + if full_text.strip(): + yield message_chunk(full_text.strip()) + + async def serialize_events(): + async for event in execution_loop(): + yield event.model_dump(exclude_none=True) + + return EventSourceResponse( + content=serialize_events(), media_type="text/event-stream" + ) diff --git a/financial-prompt-optimizer/README.md b/financial-prompt-optimizer/README.md new file mode 100644 index 0000000..68390b2 --- /dev/null +++ b/financial-prompt-optimizer/README.md @@ -0,0 +1,62 @@ +# 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. 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 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 + +### Using with OpenBB Workspace (Optional) + +- The agent descriptor is available at: 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/financial-prompt-optimizer/main.py b/financial-prompt-optimizer/main.py new file mode 100644 index 0000000..8db81e9 --- /dev/null +++ b/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) From 9aa97db58dbf57ffa9b41076b155df619d0d8027 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 12:27:02 -0400 Subject: [PATCH 02/29] list all widgets in dashboard --- .../vanilla_agent_dashboard_widgets/main.py | 367 +++++------------- 1 file changed, 93 insertions(+), 274 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index ddba95c..31bed5f 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -1,23 +1,18 @@ from __future__ import annotations from typing import AsyncGenerator -import json -import re - -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 import WidgetRequest, get_widget_data, message_chunk -from openbb_ai.models import MessageChunkSSE, QueryRequest, FunctionCallSSE - -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam, +from openbb_ai.models import ( + MessageChunkSSE, + QueryRequest, + FunctionCallSSE, + StatusUpdateSSE, + StatusUpdateSSEData, ) @@ -39,7 +34,7 @@ def get_copilot_description(): content={ "vanilla_agent_dashboard_widgets": { "name": "Vanilla Agent Dashboard Widgets", - "description": "Passes all dashboard widgets to the LLM; the model selects widgets and issues function calls when needed.", + "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": { @@ -62,281 +57,105 @@ async def query(request: QueryRequest) -> EventSourceResponse: last_message = request.messages[-1] - # 1) If the user is asking to list dashboard widgets, answer directly - def _is_widgets_list_request(text: str | None) -> bool: - if not text: - return False - q = text.lower() - if "dashboard" in q and "widget" in q: - triggers = ["what", "which", "list", "show", "available"] - return any(t in q for t in triggers) - return False - - if last_message.role == "human" and _is_widgets_list_request(last_message.content): - primary = (request.widgets.primary if request.widgets else None) or [] - secondary = (request.widgets.secondary if request.widgets else None) or [] - # Prefer the full dashboard list (secondary). Include selected if any. - secondary_names = [w.name or w.widget_id or "Unnamed Widget" for w in secondary] - primary_names = [w.name or w.widget_id or "Unnamed Widget" for w in primary] - - lines: list[str] = [] - if secondary_names: - lines.append("Widgets on your current dashboard:") - for nm in secondary_names: - lines.append(f"- {nm}") - if primary_names: - lines.append("") - lines.append("Currently selected widgets:") - for nm in primary_names: - lines.append(f"- {nm}") - - text = ( - "\n".join(lines) - if lines - else "I couldn't detect any widgets in your dashboard payload." - ) - - async def list_widgets_events(): - # Single, clean message without a leading empty chunk - yield message_chunk(text) - - return EventSourceResponse( - content=( - event.model_dump(exclude_none=True) - async for event in list_widgets_events() - ), - media_type="text/event-stream", - ) - - # 3) Auto-fetch data for explicitly selected primary widgets - if last_message.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 - }, + # If the last message is a tool result, terminate cleanly to avoid loops + if last_message.role == "tool": + async def done() -> AsyncGenerator[StatusUpdateSSE, None]: + yield StatusUpdateSSE( + data=StatusUpdateSSEData( + eventType="INFO", message="llm_complete", hidden=True ) ) - async def retrieve_widget_data_primary(): - yield get_widget_data(widget_requests) - return EventSourceResponse( - content=( - event.model_dump(exclude_none=True) - async for event in retrieve_widget_data_primary() - ), + content=(event.model_dump(exclude_none=True) async for event in done()), media_type="text/event-stream", ) - # Fallback to plain LLM response - openai_messages: list[ChatCompletionMessageParam] = [ - ChatCompletionSystemMessageParam( - role="system", - content=( - "You are a helpful financial assistant named 'Vanilla Agent'.\n" - "You have access to a list of available dashboard widgets.\n" - "When you need data, respond ONLY with a JSON object using this exact schema (no extra text):\n" - "{\n" - ' "function": "get_widget_data",\n' - ' "input_arguments": {\n' - ' "data_sources": [{\n' - ' "widget_uuid": "",\n' - ' "origin": "",\n' - ' "id": "",\n' - ' "input_args": { "": "" }\n' - " }]\n" - " }\n" - "}\n" - "Only choose widgets from the provided list. If no data is needed, answer normally." - ), - ) - ] - - context_str = "" - 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 - ) - ) - elif message.role == "tool": - # Only use the most recent tool result to avoid context bloat - if index == len(request.messages) - 1: - context_str += "Use the following data to answer the question:\n\n" - result_str = "--- Data ---\n" - try: - for result in message.data: - for item in result.items: - # Prefer 'content' if present; otherwise include URL reference - if getattr(item, "content", None): - result_str += f"{item.content}\n" - elif getattr(item, "url", None): - filename = getattr( - getattr(item, "data_format", None), "filename", None - ) - if filename: - result_str += ( - f"File available: {filename} ({item.url})\n" - ) - else: - result_str += f"File available at: {item.url}\n" - result_str += "------\n" - except Exception: - # If schema differs, safely ignore rather than failing - pass - context_str += result_str + # Widgets added to explicit context + primary = (request.widgets.primary if request.widgets else None) or [] + + # Widgets in dashboard + secondary = (request.widgets.secondary if request.widgets else None) or [] + + # File uploads or artifacts + extra = (request.widgets.extra if request.widgets else None) or [] + + # Try to use workspace tabs organization + tabs = None + active_tab_id = None + if request.workspace_state and request.workspace_state.current_dashboard_info: + tabs = request.workspace_state.current_dashboard_info.tabs or None + active_tab_id = request.workspace_state.current_dashboard_info.current_tab_id + + + sections: list[str] = [] + first_from_tabs_uuid: str | None = None + # This checks if we are in a dashboard (otherwise we may be in Apps or Widgets Library or other) + if tabs: + if active_tab_id: + sections.append(f"Active tab: {active_tab_id}\n") + else: + sections.append("No tab detected\n") + + for t in tabs: + sections.append(f"[Tab: {t.tab_id}]\n") + if t.widgets: + # record first widget uuid if we haven't yet + if first_from_tabs_uuid is None and len(t.widgets) > 0: + first_from_tabs_uuid = t.widgets[0].widget_uuid + for w in t.widgets: + sections.append(f"- {w.name}") + sections.append("") + + list_text = ( + "Widgets on your current dashboard:\n\n" + "\n".join(sections) + if sections + else "I couldn't detect any widgets in your dashboard payload." + ) - # If dashboard widgets are available, append them to context for the model to choose from. - if request.widgets and ( - request.widgets.primary or request.widgets.secondary or request.widgets.extra - ): - lines: list[str] = [ - "Available dashboard widgets (choose from these if needed):\n" - ] - for bucket_name, bucket in ( - ("primary", request.widgets.primary or []), - ("secondary", request.widgets.secondary or []), - ("extra", request.widgets.extra or []), - ): - if not bucket: - continue - lines.append(f"[{bucket_name}]\n") - for w in bucket: - # Render params: name + current_value snapshot - param_pairs = [ - f"{p.name}={(p.current_value if hasattr(p, 'current_value') else None)}" - for p in w.params - ] - params_str = ", ".join(param_pairs) - lines.append( - "- uuid=" - + str(getattr(w, "uuid", "")) - + ", id=" - + (w.widget_id or "") - + ", origin=" - + (w.origin or "") - + ", name=" - + (w.name or "") - + (f", params: {params_str}" if params_str else "") + async def events() -> AsyncGenerator[MessageChunkSSE | FunctionCallSSE | StatusUpdateSSE, None]: + # First, emit the list message + yield message_chunk(list_text) + # Emit an empty chunk to mark end of the textual message + yield message_chunk("") + + # Then, retrieve data for the first widget found + first = None + # Prefer first widget in the active tab (if tabs are provided) + if first_from_tabs_uuid: + # Look up the full widget object by UUID across all available buckets + all_widgets = list(primary) + list(secondary) + list(extra) + for w in all_widgets: + if str(getattr(w, "uuid", "")) == str(first_from_tabs_uuid): + first = w + break + # Fallback order if tabs not available or not found + if first is None: + if primary: + first = primary[0] + elif secondary: + first = secondary[0] + elif extra: + first = extra[0] + + if first is not None: + input_args = { + p.name: ( + getattr(p, "current_value", None) + if getattr(p, "current_value", None) is not None + else getattr(p, "default_value", None) ) - lines.append("") - if lines: - context_str += "\n" + "\n".join(lines) - - # If we have context from the latest tool message, append it to the last user/assistant message - if context_str and len(openai_messages) > 1: - try: - openai_messages[-1]["content"] += "\n\n" + context_str # type: ignore[index] - except Exception: - # If last message isn't content-bearing, attach to system as fallback - openai_messages[0]["content"] += "\n\n" + context_str # type: ignore[index] - - def _strip_code_fences(text: str) -> str: - # Remove ```json ... ``` or ``` ... ``` fences if present - fence_match = re.search(r"```(?:json|JSON)?\s*([\s\S]*?)\s*```", text) - if fence_match: - return fence_match.group(1).strip() - return text.strip() - - def _extract_json_object(text: str) -> dict | None: - # Try direct parse, then strip common code fences and retry. - for candidate in (text, _strip_code_fences(text)): - try: - obj = json.loads(candidate) - if isinstance(obj, dict): - return obj - except Exception: - continue - return None - - async def execution_loop() -> ( - AsyncGenerator[MessageChunkSSE | FunctionCallSSE, None] - ): - client = openai.AsyncOpenAI() + for p in first.params + } + yield get_widget_data([WidgetRequest(widget=first, input_arguments=input_args)]) - stream = await client.chat.completions.create( - model="gpt-4o", - messages=openai_messages, - stream=True, + # Finally, emit a completion signal to terminate the stream + yield StatusUpdateSSE( + data=StatusUpdateSSEData(eventType="INFO", message="llm_complete", hidden=True) ) - full_text = "" - async for event in stream: - if chunk := event.choices[0].delta.content: - full_text += chunk - - # Try to interpret the model output as a function call request - try: - data = _extract_json_object(full_text) - # Accept both `get_widget_data` and escaped variants (defensive) - func = (data or {}).get("function") if isinstance(data, dict) else None - if ( - isinstance(data, dict) - and func - and func.replace("\\_", "_") == "get_widget_data" - ): - # Build WidgetRequest list from declared data_sources by resolving - # widget UUIDs against the provided widgets in the request - ds_list = data.get("input_arguments", {}).get("data_sources", []) or [] - # Flatten available widgets for lookup - all_widgets = [] - if request.widgets: - for col in [ - request.widgets.primary or [], - request.widgets.secondary or [], - request.widgets.extra or [], - ]: - all_widgets.extend(col) - - def find_widget(uuid: str | None, wid: str | None): - if uuid: - for w in all_widgets: - if str(getattr(w, "uuid", "")) == str(uuid): - return w - if wid: - for w in all_widgets: - if (w.widget_id or "") == wid: - return w - return None - - widget_requests: list[WidgetRequest] = [] - for ds in ds_list: - w_uuid = ds.get("widget_uuid") - w_id = ds.get("id") - widget = find_widget(w_uuid, w_id) - if not widget: - continue - input_args = ds.get("input_args") or { - p.name: p.current_value for p in widget.params - } - widget_requests.append( - WidgetRequest(widget=widget, input_arguments=input_args) - ) - - if widget_requests: - # Emit a typed function call SSE so the UI retrieves data - yield get_widget_data(widget_requests) - return - except Exception: - # Not a function call; continue to output accumulated text - pass - - # If no function call was detected, stream the final accumulated text (single message) - if full_text.strip(): - yield message_chunk(full_text.strip()) - async def serialize_events(): - async for event in execution_loop(): + async for event in events(): yield event.model_dump(exclude_none=True) return EventSourceResponse( From efc8f1e4785a98d450954486bc03b7b27e4fc72d Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 12:47:06 -0400 Subject: [PATCH 03/29] better --- .../vanilla_agent_dashboard_widgets/main.py | 164 ++++++++++++++++-- 1 file changed, 153 insertions(+), 11 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 31bed5f..135e702 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -1,12 +1,15 @@ from __future__ import annotations from typing import AsyncGenerator +import json +import csv +from io import StringIO from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from sse_starlette.sse import EventSourceResponse -from openbb_ai import WidgetRequest, get_widget_data, message_chunk +from openbb_ai import WidgetRequest, get_widget_data, message_chunk, table from openbb_ai.models import ( MessageChunkSSE, QueryRequest, @@ -57,9 +60,117 @@ async def query(request: QueryRequest) -> EventSourceResponse: last_message = request.messages[-1] - # If the last message is a tool result, terminate cleanly to avoid loops + # If the last message is a tool result, display a small preview and then terminate. if last_message.role == "tool": - async def done() -> AsyncGenerator[StatusUpdateSSE, None]: + async def show_preview_and_done() -> AsyncGenerator[ + MessageChunkSSE | StatusUpdateSSE, None + ]: + # Announce which widget we retrieved + widget_name = None + try: + ds_list = (last_message.input_arguments or {}).get("data_sources", []) + target_uuid = None + if ds_list: + target_uuid = ds_list[0].get("widget_uuid") + widget_name = ds_list[0].get("id") + + if target_uuid and request.widgets: + for col in [ + request.widgets.primary or [], + request.widgets.secondary or [], + request.widgets.extra or [], + ]: + for w in col: + if str(getattr(w, "uuid", "")) == str(target_uuid): + widget_name = w.name or w.widget_id + raise StopIteration + except StopIteration: + pass + except Exception: + pass + + if widget_name: + yield message_chunk(f"Retrieving data from: {widget_name}") + # Best-effort parse of the first data content into a small table preview + preview_rows: list[dict] | None = None + try: + if last_message.data: + # Find the first textual content + content_str = None + for result in last_message.data: + for item in getattr(result, "items", []) or []: + if getattr(item, "content", None): + content_str = item.content + break + if content_str: + break + + if content_str: + # Try JSON + parsed = None + try: + parsed = json.loads(content_str) + except Exception: + parsed = None + + def to_rows(obj) -> list[dict] | None: + if isinstance(obj, list): + if not obj: + return [] + # list of dicts + if isinstance(obj[0], dict): + return obj + # list of lists with header in first row + if ( + isinstance(obj[0], list) + and len(obj) > 1 + and all(isinstance(x, (str, int, float, bool, type(None))) for x in obj[0]) + ): + headers = [str(h) for h in obj[0]] + return [ + {headers[i]: r[i] if i < len(r) else None} + for r in obj[1:] + ] + if isinstance(obj, dict): + # Common keys that may contain rows + for key in ("data", "rows", "records", "items", "result"): + val = obj.get(key) + rows = to_rows(val) + if rows is not None: + return rows + return None + + rows = to_rows(parsed) if parsed is not None else None + + if rows is None: + # Try CSV + try: + reader = csv.DictReader(StringIO(content_str)) + rows = list(reader) + except Exception: + rows = None + + if rows is None: + # As a last resort, show raw text snippet + yield message_chunk("Preview (raw):\n" + content_str[:500]) + else: + # Limit to first 5 columns and 10 rows + if rows: + cols = list(rows[0].keys())[:5] + preview_rows = [ + {c: r.get(c) for c in cols} for r in rows[:10] + ] + except Exception: + preview_rows = None + + if preview_rows: + yield table( + data=preview_rows, + name="Widget Preview", + description="First 10 rows × 5 columns", + ) + + # Completion signal yield StatusUpdateSSE( data=StatusUpdateSSEData( eventType="INFO", message="llm_complete", hidden=True @@ -67,7 +178,10 @@ async def done() -> AsyncGenerator[StatusUpdateSSE, None]: ) return EventSourceResponse( - content=(event.model_dump(exclude_none=True) async for event in done()), + content=( + event.model_dump(exclude_none=True) + async for event in show_preview_and_done() + ), media_type="text/event-stream", ) @@ -92,21 +206,49 @@ async def done() -> AsyncGenerator[StatusUpdateSSE, None]: first_from_tabs_uuid: str | None = None # This checks if we are in a dashboard (otherwise we may be in Apps or Widgets Library or other) if tabs: + # Prefer the first widget in the active tab if available + if active_tab_id and any(t.tab_id == active_tab_id for t in tabs): + active_tab = next(t for t in tabs if t.tab_id == active_tab_id) + if active_tab.widgets and len(active_tab.widgets) > 0: + first_from_tabs_uuid = active_tab.widgets[0].widget_uuid + + # If not found, fallback to the first widget of the first tab with widgets + if first_from_tabs_uuid is None: + for t in tabs: + if t.widgets and len(t.widgets) > 0: + first_from_tabs_uuid = t.widgets[0].widget_uuid + break + + # Build visual listing grouped by tabs if active_tab_id: sections.append(f"Active tab: {active_tab_id}\n") else: sections.append("No tab detected\n") - for t in tabs: sections.append(f"[Tab: {t.tab_id}]\n") if t.widgets: - # record first widget uuid if we haven't yet - if first_from_tabs_uuid is None and len(t.widgets) > 0: - first_from_tabs_uuid = t.widgets[0].widget_uuid for w in t.widgets: sections.append(f"- {w.name}") sections.append("") + # Fallback listing by primary/secondary/extra if no tabs detected + if not sections: + if primary: + sections.append("[Primary]\n") + for w in primary: + sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") + sections.append("") + if secondary: + sections.append("[Secondary]\n") + for w in secondary: + sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") + sections.append("") + if extra: + sections.append("[Extra]\n") + for w in extra: + sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") + sections.append("") + list_text = ( "Widgets on your current dashboard:\n\n" + "\n".join(sections) if sections @@ -116,8 +258,6 @@ async def done() -> AsyncGenerator[StatusUpdateSSE, None]: async def events() -> AsyncGenerator[MessageChunkSSE | FunctionCallSSE | StatusUpdateSSE, None]: # First, emit the list message yield message_chunk(list_text) - # Emit an empty chunk to mark end of the textual message - yield message_chunk("") # Then, retrieve data for the first widget found first = None @@ -148,8 +288,10 @@ async def events() -> AsyncGenerator[MessageChunkSSE | FunctionCallSSE | StatusU for p in first.params } yield get_widget_data([WidgetRequest(widget=first, input_arguments=input_args)]) + # IMPORTANT: Close immediately after function call; UI will send tool result next + return - # Finally, emit a completion signal to terminate the stream + # No widget found; emit completion to terminate the stream yield StatusUpdateSSE( data=StatusUpdateSSEData(eventType="INFO", message="llm_complete", hidden=True) ) From e7f741c9842bd2cd96b206ac830a91b7330fda35 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 14:40:13 -0400 Subject: [PATCH 04/29] better --- .../vanilla_agent_dashboard_widgets/main.py | 440 ++++++++---------- 1 file changed, 187 insertions(+), 253 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 135e702..6af9e36 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -1,21 +1,19 @@ -from __future__ import annotations - from typing import AsyncGenerator -import json -import csv -from io import StringIO +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 import WidgetRequest, get_widget_data, message_chunk, table -from openbb_ai.models import ( - MessageChunkSSE, - QueryRequest, - FunctionCallSSE, - StatusUpdateSSE, - StatusUpdateSSEData, +from openbb_ai.models import MessageChunkSSE, QueryRequest +from openbb_ai import get_widget_data, WidgetRequest, message_chunk + +from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionUserMessageParam, + ChatCompletionAssistantMessageParam, + ChatCompletionSystemMessageParam, ) @@ -52,254 +50,190 @@ def get_copilot_description(): @app.post("/v1/query") async def query(request: QueryRequest) -> EventSourceResponse: - """Stream either a function call or an AI answer.""" - if not request.messages: - return JSONResponse( - status_code=400, content={"detail": "messages list cannot be empty"} - ) # type: ignore[return-value] - - last_message = request.messages[-1] - - # If the last message is a tool result, display a small preview and then terminate. - if last_message.role == "tool": - async def show_preview_and_done() -> AsyncGenerator[ - MessageChunkSSE | StatusUpdateSSE, None - ]: - # Announce which widget we retrieved - widget_name = None - try: - ds_list = (last_message.input_arguments or {}).get("data_sources", []) - target_uuid = None - if ds_list: - target_uuid = ds_list[0].get("widget_uuid") - widget_name = ds_list[0].get("id") - - if target_uuid and request.widgets: - for col in [ - request.widgets.primary or [], - request.widgets.secondary or [], - request.widgets.extra or [], - ]: - for w in col: - if str(getattr(w, "uuid", "")) == str(target_uuid): - widget_name = w.name or w.widget_id - raise StopIteration - except StopIteration: - pass - except Exception: - pass - - if widget_name: - yield message_chunk(f"Retrieving data from: {widget_name}") - # Best-effort parse of the first data content into a small table preview - preview_rows: list[dict] | None = None - try: - if last_message.data: - # Find the first textual content - content_str = None - for result in last_message.data: - for item in getattr(result, "items", []) or []: - if getattr(item, "content", None): - content_str = item.content - break - if content_str: - break - - if content_str: - # Try JSON - parsed = None - try: - parsed = json.loads(content_str) - except Exception: - parsed = None - - def to_rows(obj) -> list[dict] | None: - if isinstance(obj, list): - if not obj: - return [] - # list of dicts - if isinstance(obj[0], dict): - return obj - # list of lists with header in first row - if ( - isinstance(obj[0], list) - and len(obj) > 1 - and all(isinstance(x, (str, int, float, bool, type(None))) for x in obj[0]) - ): - headers = [str(h) for h in obj[0]] - return [ - {headers[i]: r[i] if i < len(r) else None} - for r in obj[1:] - ] - if isinstance(obj, dict): - # Common keys that may contain rows - for key in ("data", "rows", "records", "items", "result"): - val = obj.get(key) - rows = to_rows(val) - if rows is not None: - return rows - return None - - rows = to_rows(parsed) if parsed is not None else None - - if rows is None: - # Try CSV - try: - reader = csv.DictReader(StringIO(content_str)) - rows = list(reader) - except Exception: - rows = None - - if rows is None: - # As a last resort, show raw text snippet - yield message_chunk("Preview (raw):\n" + content_str[:500]) - else: - # Limit to first 5 columns and 10 rows - if rows: - cols = list(rows[0].keys())[:5] - preview_rows = [ - {c: r.get(c) for c in cols} for r in rows[:10] - ] - except Exception: - preview_rows = None - - if preview_rows: - yield table( - data=preview_rows, - name="Widget Preview", - description="First 10 rows × 5 columns", - ) - - # Completion signal - yield StatusUpdateSSE( - data=StatusUpdateSSEData( - eventType="INFO", message="llm_complete", hidden=True - ) - ) - + """Query the Copilot.""" + + # Helper to get all widgets + def get_all_widgets(): + widgets = [] + if request.widgets: + if request.widgets.primary: + widgets.extend(request.widgets.primary) + if request.widgets.secondary: + widgets.extend(request.widgets.secondary) + if request.widgets.extra: + widgets.extend(request.widgets.extra) + return widgets + + # Always show widget list and fetch last widget data on human messages + if request.messages[-1].role == "human": + all_widgets = get_all_widgets() + + # Build widget list to show + widget_list_msg = "" + 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 = "Widgets on your current dashboard:\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" + if tab.widgets: + for widget in tab.widgets: + widget_list_msg += f" - {widget.name}\n" + else: + widget_list_msg += " (no widgets)\n" + widget_list_msg += "\n" + elif all_widgets: + widget_list_msg = "Available widgets:\n\n" + for w in all_widgets: + widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + widget_list_msg += "\n" + + # Stream widget list and then fetch last widget data + async def show_widgets_and_fetch(): + # Show the widget list (first message completes here) + if widget_list_msg: + yield message_chunk(widget_list_msg.rstrip()).model_dump() + + # Then fetch data from the last widget if available + if all_widgets: + last_widget = all_widgets[-1] + widget_requests = [ + WidgetRequest( + widget=last_widget, + input_arguments={ + param.name: param.current_value for param in last_widget.params + }, + ) + ] + yield get_widget_data(widget_requests).model_dump() + elif not widget_list_msg: + yield message_chunk("No widgets found on your dashboard.").model_dump() + + # Return early with widget info and data fetch return EventSourceResponse( - content=( - event.model_dump(exclude_none=True) - async for event in show_preview_and_done() - ), + content=show_widgets_and_fetch(), media_type="text/event-stream", ) - # Widgets added to explicit context - primary = (request.widgets.primary if request.widgets else None) or [] - - # Widgets in dashboard - secondary = (request.widgets.secondary if request.widgets else None) or [] - - # File uploads or artifacts - extra = (request.widgets.extra if request.widgets else None) or [] - - # Try to use workspace tabs organization - tabs = None - active_tab_id = None - if request.workspace_state and request.workspace_state.current_dashboard_info: - tabs = request.workspace_state.current_dashboard_info.tabs or None - active_tab_id = request.workspace_state.current_dashboard_info.current_tab_id - - - sections: list[str] = [] - first_from_tabs_uuid: str | None = None - # This checks if we are in a dashboard (otherwise we may be in Apps or Widgets Library or other) - if tabs: - # Prefer the first widget in the active tab if available - if active_tab_id and any(t.tab_id == active_tab_id for t in tabs): - active_tab = next(t for t in tabs if t.tab_id == active_tab_id) - if active_tab.widgets and len(active_tab.widgets) > 0: - first_from_tabs_uuid = active_tab.widgets[0].widget_uuid - - # If not found, fallback to the first widget of the first tab with widgets - if first_from_tabs_uuid is None: - for t in tabs: - if t.widgets and len(t.widgets) > 0: - first_from_tabs_uuid = t.widgets[0].widget_uuid + # Check if we just received tool data - if so, show a sample and continue conversation + if request.messages and request.messages[-1].role == "tool": + async def show_data_sample(): + # Extract widget name and data + widget_name = "Unknown Widget" + all_widgets = get_all_widgets() + if all_widgets: + last_widget = all_widgets[-1] + widget_name = last_widget.name or last_widget.widget_id or 'Unnamed' + + # Extract data content + data_content = "" + for result in request.messages[-1].data: + for item in result.items: + data_content = item.content break - - # Build visual listing grouped by tabs - if active_tab_id: - sections.append(f"Active tab: {active_tab_id}\n") - else: - sections.append("No tab detected\n") - for t in tabs: - sections.append(f"[Tab: {t.tab_id}]\n") - if t.widgets: - for w in t.widgets: - sections.append(f"- {w.name}") - sections.append("") - - # Fallback listing by primary/secondary/extra if no tabs detected - if not sections: - if primary: - sections.append("[Primary]\n") - for w in primary: - sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") - sections.append("") - if secondary: - sections.append("[Secondary]\n") - for w in secondary: - sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") - sections.append("") - if extra: - sections.append("[Extra]\n") - for w in extra: - sections.append(f"- {w.name or w.widget_id or 'Unnamed Widget'}") - sections.append("") - - list_text = ( - "Widgets on your current dashboard:\n\n" + "\n".join(sections) - if sections - else "I couldn't detect any widgets in your dashboard payload." - ) - - async def events() -> AsyncGenerator[MessageChunkSSE | FunctionCallSSE | StatusUpdateSSE, None]: - # First, emit the list message - yield message_chunk(list_text) - - # Then, retrieve data for the first widget found - first = None - # Prefer first widget in the active tab (if tabs are provided) - if first_from_tabs_uuid: - # Look up the full widget object by UUID across all available buckets - all_widgets = list(primary) + list(secondary) + list(extra) - for w in all_widgets: - if str(getattr(w, "uuid", "")) == str(first_from_tabs_uuid): - first = w + if data_content: break - # Fallback order if tabs not available or not found - if first is None: - if primary: - first = primary[0] - elif secondary: - first = secondary[0] - elif extra: - first = extra[0] - - if first is not None: - input_args = { - p.name: ( - getattr(p, "current_value", None) - if getattr(p, "current_value", None) is not None - else getattr(p, "default_value", None) - ) - for p in first.params - } - yield get_widget_data([WidgetRequest(widget=first, input_arguments=input_args)]) - # IMPORTANT: Close immediately after function call; UI will send tool result next - return - - # No widget found; emit completion to terminate the stream - yield StatusUpdateSSE( - data=StatusUpdateSSEData(eventType="INFO", message="llm_complete", hidden=True) + + if data_content: + sample = data_content[:500] + "..." if len(data_content) > 500 else data_content + yield message_chunk(f"Fetching sample data from last widget: {widget_name}\n\nSample of widget data:\n```\n{sample}\n```").model_dump() + + return EventSourceResponse( + content=show_data_sample(), + 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 'Dashboard Widget Agent'.", + ) + ] - async def serialize_events(): - async for event in events(): - yield event.model_dump(exclude_none=True) - + context_str = "" + 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 + ) + ) + # Add widget data to context if it's a tool message (most recent only) + elif message.role == "tool" and index == len(request.messages) - 1: + context_str += "Use the following data to answer the question:\n\n" + result_str = "--- Data ---\n" + for result in message.data: + for item in result.items: + result_str += f"{item.content}\n" + result_str += "------\n" + context_str += result_str + + # Build comprehensive widget listing + widget_list = "" + all_widgets = get_all_widgets() + + # Show widgets organized by tabs if available + 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 = "Widgets on your current dashboard:\n\n" + if active_tab: + widget_list += f"Active tab: {active_tab}\n\n" + + for tab in tabs: + widget_list += f"[Tab: {tab.tab_id}]\n" + if tab.widgets: + for widget in tab.widgets: + widget_list += f" - {widget.name}\n" + else: + widget_list += " (no widgets)\n" + widget_list += "\n" + elif all_widgets: + # Fallback: list all widgets if no tab info + widget_list = "Available widgets:\n\n" + for w in all_widgets: + widget_list += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + widget_list += "\n" + + # Add widget list and data context to the last user message + if widget_list or context_str: + full_context = "" + if widget_list: + full_context += widget_list + if context_str: + if all_widgets: + last_widget = all_widgets[-1] + full_context += (f"Data from last widget " + f"({last_widget.name or last_widget.widget_id or 'Unnamed'}):\n\n") + full_context += context_str + openai_messages[-1]["content"] += "\n\n" + full_context # type: ignore + + # Define the execution loop to stream LLM response + 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).model_dump() + + # Stream the SSEs back to the client return EventSourceResponse( - content=serialize_events(), media_type="text/event-stream" + content=execution_loop(), + media_type="text/event-stream" ) From a843cc4d0c0b27d9d5496c0a3dd0b294b50a4f77 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 14:52:37 -0400 Subject: [PATCH 05/29] cleaned up version --- .../vanilla_agent_dashboard_widgets/main.py | 217 +++++------------- 1 file changed, 61 insertions(+), 156 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 6af9e36..e2f1100 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -1,21 +1,11 @@ -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.models import QueryRequest from openbb_ai import get_widget_data, WidgetRequest, message_chunk -from openai.types.chat import ( - ChatCompletionMessageParam, - ChatCompletionUserMessageParam, - ChatCompletionAssistantMessageParam, - ChatCompletionSystemMessageParam, -) - app = FastAPI() @@ -52,46 +42,43 @@ def get_copilot_description(): async def query(request: QueryRequest) -> EventSourceResponse: """Query the Copilot.""" - # Helper to get all widgets - def get_all_widgets(): - widgets = [] - if request.widgets: - if request.widgets.primary: - widgets.extend(request.widgets.primary) - if request.widgets.secondary: - widgets.extend(request.widgets.secondary) - if request.widgets.extra: - widgets.extend(request.widgets.extra) - return widgets + # Get all widgets from dashboard (used in multiple places) + all_widgets = [] + if request.widgets: + if request.widgets.primary: + all_widgets.extend(request.widgets.primary) + if request.widgets.secondary: + all_widgets.extend(request.widgets.secondary) + if request.widgets.extra: + all_widgets.extend(request.widgets.extra) + + # Build widget list string (used in multiple places) + widget_list_msg = "" + 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 = "Widgets on your current dashboard:\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" + if tab.widgets: + for widget in tab.widgets: + widget_list_msg += f" - {widget.name}\n" + else: + widget_list_msg += " (no widgets)\n" + widget_list_msg += "\n" + elif all_widgets: + widget_list_msg = "Available widgets:\n\n" + for w in all_widgets: + widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + widget_list_msg += "\n" # Always show widget list and fetch last widget data on human messages if request.messages[-1].role == "human": - all_widgets = get_all_widgets() - - # Build widget list to show - widget_list_msg = "" - 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 = "Widgets on your current dashboard:\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" - if tab.widgets: - for widget in tab.widgets: - widget_list_msg += f" - {widget.name}\n" - else: - widget_list_msg += " (no widgets)\n" - widget_list_msg += "\n" - elif all_widgets: - widget_list_msg = "Available widgets:\n\n" - for w in all_widgets: - widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" - widget_list_msg += "\n" # Stream widget list and then fetch last widget data async def show_widgets_and_fetch(): @@ -122,118 +109,36 @@ async def show_widgets_and_fetch(): # Check if we just received tool data - if so, show a sample and continue conversation if request.messages and request.messages[-1].role == "tool": - async def show_data_sample(): - # Extract widget name and data - widget_name = "Unknown Widget" - all_widgets = get_all_widgets() - if all_widgets: - last_widget = all_widgets[-1] - widget_name = last_widget.name or last_widget.widget_id or 'Unnamed' - - # Extract data content - data_content = "" - for result in request.messages[-1].data: - for item in result.items: - data_content = item.content - break - if data_content: - break - + # Extract widget name and data + widget_name = "Unknown Widget" + if all_widgets: + last_widget = all_widgets[-1] + widget_name = last_widget.name or last_widget.widget_id or 'Unnamed' + + # Extract data content + data_content = "" + for result in request.messages[-1].data: + for item in result.items: + data_content = item.content + break if data_content: - sample = data_content[:500] + "..." if len(data_content) > 500 else data_content - yield message_chunk(f"Fetching sample data from last widget: {widget_name}\n\nSample of widget data:\n```\n{sample}\n```").model_dump() + break - return EventSourceResponse( - content=show_data_sample(), - 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 'Dashboard Widget Agent'.", - ) - ] - - context_str = "" - for index, message in enumerate(request.messages): - if message.role == "human": - openai_messages.append( - ChatCompletionUserMessageParam(role="user", content=message.content) + if data_content: + sample = data_content[:500] + "..." if len(data_content) > 500 else data_content + async def show_data_sample(): + yield message_chunk(f"Fetching sample data from last widget: {widget_name}\n\nSample of widget data:\n```\n{sample}\n```").model_dump() + + return EventSourceResponse( + content=show_data_sample(), + media_type="text/event-stream", ) - elif message.role == "ai": - if isinstance(message.content, str): - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", content=message.content - ) - ) - # Add widget data to context if it's a tool message (most recent only) - elif message.role == "tool" and index == len(request.messages) - 1: - context_str += "Use the following data to answer the question:\n\n" - result_str = "--- Data ---\n" - for result in message.data: - for item in result.items: - result_str += f"{item.content}\n" - result_str += "------\n" - context_str += result_str - - # Build comprehensive widget listing - widget_list = "" - all_widgets = get_all_widgets() - # Show widgets organized by tabs if available - 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 = "Widgets on your current dashboard:\n\n" - if active_tab: - widget_list += f"Active tab: {active_tab}\n\n" - - for tab in tabs: - widget_list += f"[Tab: {tab.tab_id}]\n" - if tab.widgets: - for widget in tab.widgets: - widget_list += f" - {widget.name}\n" - else: - widget_list += " (no widgets)\n" - widget_list += "\n" - elif all_widgets: - # Fallback: list all widgets if no tab info - widget_list = "Available widgets:\n\n" - for w in all_widgets: - widget_list += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" - widget_list += "\n" - - # Add widget list and data context to the last user message - if widget_list or context_str: - full_context = "" - if widget_list: - full_context += widget_list - if context_str: - if all_widgets: - last_widget = all_widgets[-1] - full_context += (f"Data from last widget " - f"({last_widget.name or last_widget.widget_id or 'Unnamed'}):\n\n") - full_context += context_str - openai_messages[-1]["content"] += "\n\n" + full_context # type: ignore - - # Define the execution loop to stream LLM response - 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).model_dump() - - # Stream the SSEs back to the client + # 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=execution_loop(), + content=empty_response(), media_type="text/event-stream" ) From 64b1df0a3f4036d2e05821a4bd39493607ab0f96 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 14:57:48 -0400 Subject: [PATCH 06/29] separate explicit from dashboard --- .../vanilla_agent_dashboard_widgets/main.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index e2f1100..52fe122 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -52,14 +52,23 @@ async def query(request: QueryRequest) -> EventSourceResponse: if request.widgets.extra: all_widgets.extend(request.widgets.extra) - # Build widget list string (used in multiple places) + # Build widget list string with separation between explicit and dashboard context widget_list_msg = "" + + # Explicit Context (Primary) - widgets explicitly selected + if request.widgets and request.widgets.primary: + widget_list_msg += "**Explicit Context (Primary)**\n" + for w in request.widgets.primary: + widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + widget_list_msg += "\n" + + # Dashboard Context (Secondary) - widgets from current dashboard 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 = "Widgets on your current dashboard:\n\n" + widget_list_msg += "**Dashboard Context (Secondary)**\n\n" if active_tab: widget_list_msg += f"Active tab: {active_tab}\n\n" @@ -71,8 +80,9 @@ async def query(request: QueryRequest) -> EventSourceResponse: else: widget_list_msg += " (no widgets)\n" widget_list_msg += "\n" - elif all_widgets: - widget_list_msg = "Available widgets:\n\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 += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" widget_list_msg += "\n" From 1161d81e6b4b0f31d2524491181130fe7eeaf5f3 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 15:15:47 -0400 Subject: [PATCH 07/29] nicer formatting --- .../vanilla_agent_dashboard_widgets/main.py | 119 +++++++++++++++++- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 52fe122..d2bd622 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -57,9 +57,41 @@ async def query(request: QueryRequest) -> EventSourceResponse: # Explicit Context (Primary) - widgets explicitly selected if request.widgets and request.widgets.primary: - widget_list_msg += "**Explicit Context (Primary)**\n" + widget_list_msg += "# Explicit Context (Primary)\n\n" for w in request.widgets.primary: - widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + + widget_list_msg += f"### {w.name or w.widget_id or 'Unnamed Widget'}\n\n" + widget_list_msg += "| Field | Value |\n" + widget_list_msg += "|-------|-------|\n" + widget_list_msg += f"| Name | {w.name or 'N/A'} |\n" + widget_list_msg += f"| Description | {w.description or 'N/A'} |\n" + widget_list_msg += f"| ID | {w.widget_id or 'N/A'} |\n" + widget_list_msg += f"| Category | {getattr(w, 'category', 'N/A') or 'N/A'} |\n" + widget_list_msg += f"| UUID | {getattr(w, 'uuid', 'N/A') or 'N/A'} |\n\n" + + # Parameters table + if w.params: + widget_list_msg += f"#### {w.name or w.widget_id or 'Unnamed Widget'} Parameters\n\n" + widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" + widget_list_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] + '...' + widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" + widget_list_msg += "\n" widget_list_msg += "\n" # Dashboard Context (Secondary) - widgets from current dashboard @@ -68,15 +100,59 @@ async def query(request: QueryRequest) -> EventSourceResponse: active_tab = request.workspace_state.current_dashboard_info.current_tab_id if tabs: - widget_list_msg += "**Dashboard Context (Secondary)**\n\n" + 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" + widget_list_msg += f"## Tab: {tab.tab_id}\n\n" if tab.widgets: for widget in tab.widgets: - widget_list_msg += f" - {widget.name}\n" + # 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 + + 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" + if full_widget: + widget_list_msg += f"| Description | {full_widget.description or 'N/A'} |\n" + widget_list_msg += f"| ID | {full_widget.widget_id or 'N/A'} |\n" + widget_list_msg += f"| Category | {getattr(full_widget, 'category', 'N/A') or 'N/A'} |\n" + widget_list_msg += f"| UUID | {widget.widget_uuid or 'N/A'} |\n\n" + + # Parameters table + if full_widget.params: + widget_list_msg += f"#### {widget.name} Parameters\n\n" + widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" + widget_list_msg += "|-----------|------|---------|---------|---------|-------------|\n" + for p in full_widget.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] + '...' + widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" + widget_list_msg += "\n" + else: + widget_list_msg += "| Description | N/A |\n" + widget_list_msg += "| ID | N/A |\n" + widget_list_msg += "| Category | 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" @@ -84,7 +160,38 @@ async def query(request: QueryRequest) -> EventSourceResponse: # Fallback if no primary widgets shown above widget_list_msg += "**Available widgets**\n\n" for w in all_widgets: - widget_list_msg += f" - {w.name or w.widget_id or 'Unnamed Widget'}\n" + widget_list_msg += f"### {w.name or w.widget_id or 'Unnamed Widget'}\n\n" + widget_list_msg += "| Field | Value |\n" + widget_list_msg += "|-------|-------|\n" + widget_list_msg += f"| Name | {w.name or 'N/A'} |\n" + widget_list_msg += f"| Description | {w.description or 'N/A'} |\n" + widget_list_msg += f"| ID | {w.widget_id or 'N/A'} |\n" + widget_list_msg += f"| Category | {getattr(w, 'category', 'N/A') or 'N/A'} |\n" + widget_list_msg += f"| UUID | {getattr(w, 'uuid', 'N/A') or 'N/A'} |\n\n" + + # Parameters table + if w.params: + widget_list_msg += "**Parameters:**\n\n" + widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" + widget_list_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] + '...' + widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" + widget_list_msg += "\n" widget_list_msg += "\n" # Always show widget list and fetch last widget data on human messages From d579304d044b02c88474f3a3fbd8cfcbf78f2d07 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 15:20:36 -0400 Subject: [PATCH 08/29] clean up code --- .../vanilla_agent_dashboard_widgets/main.py | 144 ++++++------------ 1 file changed, 44 insertions(+), 100 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index d2bd622..dc452e9 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -52,6 +52,42 @@ async def query(request: QueryRequest) -> EventSourceResponse: if request.widgets.extra: all_widgets.extend(request.widgets.extra) + # 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" + msg += f"| Description | {w.description or 'N/A'} |\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 + # Build widget list string with separation between explicit and dashboard context widget_list_msg = "" @@ -59,39 +95,7 @@ async def query(request: QueryRequest) -> EventSourceResponse: if request.widgets and request.widgets.primary: widget_list_msg += "# Explicit Context (Primary)\n\n" for w in request.widgets.primary: - - widget_list_msg += f"### {w.name or w.widget_id or 'Unnamed Widget'}\n\n" - widget_list_msg += "| Field | Value |\n" - widget_list_msg += "|-------|-------|\n" - widget_list_msg += f"| Name | {w.name or 'N/A'} |\n" - widget_list_msg += f"| Description | {w.description or 'N/A'} |\n" - widget_list_msg += f"| ID | {w.widget_id or 'N/A'} |\n" - widget_list_msg += f"| Category | {getattr(w, 'category', 'N/A') or 'N/A'} |\n" - widget_list_msg += f"| UUID | {getattr(w, 'uuid', 'N/A') or 'N/A'} |\n\n" - - # Parameters table - if w.params: - widget_list_msg += f"#### {w.name or w.widget_id or 'Unnamed Widget'} Parameters\n\n" - widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" - widget_list_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] + '...' - widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" - widget_list_msg += "\n" + widget_list_msg += format_widget(w) widget_list_msg += "\n" # Dashboard Context (Secondary) - widgets from current dashboard @@ -115,43 +119,14 @@ async def query(request: QueryRequest) -> EventSourceResponse: full_widget = w break - 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" if full_widget: - widget_list_msg += f"| Description | {full_widget.description or 'N/A'} |\n" - widget_list_msg += f"| ID | {full_widget.widget_id or 'N/A'} |\n" - widget_list_msg += f"| Category | {getattr(full_widget, 'category', 'N/A') or 'N/A'} |\n" - widget_list_msg += f"| UUID | {widget.widget_uuid or 'N/A'} |\n\n" - - # Parameters table - if full_widget.params: - widget_list_msg += f"#### {widget.name} Parameters\n\n" - widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" - widget_list_msg += "|-----------|------|---------|---------|---------|-------------|\n" - for p in full_widget.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] + '...' - widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" - widget_list_msg += "\n" + widget_list_msg += format_widget(full_widget) else: - widget_list_msg += "| Description | N/A |\n" - widget_list_msg += "| ID | N/A |\n" - widget_list_msg += "| Category | N/A |\n" + # 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" @@ -160,38 +135,7 @@ async def query(request: QueryRequest) -> EventSourceResponse: # Fallback if no primary widgets shown above widget_list_msg += "**Available widgets**\n\n" for w in all_widgets: - widget_list_msg += f"### {w.name or w.widget_id or 'Unnamed Widget'}\n\n" - widget_list_msg += "| Field | Value |\n" - widget_list_msg += "|-------|-------|\n" - widget_list_msg += f"| Name | {w.name or 'N/A'} |\n" - widget_list_msg += f"| Description | {w.description or 'N/A'} |\n" - widget_list_msg += f"| ID | {w.widget_id or 'N/A'} |\n" - widget_list_msg += f"| Category | {getattr(w, 'category', 'N/A') or 'N/A'} |\n" - widget_list_msg += f"| UUID | {getattr(w, 'uuid', 'N/A') or 'N/A'} |\n\n" - - # Parameters table - if w.params: - widget_list_msg += "**Parameters:**\n\n" - widget_list_msg += "| Parameter | Type | Default | Current | Options | Description |\n" - widget_list_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] + '...' - widget_list_msg += f"| {p.name} | {param_type} | {default_val} | {current_val} | {options_str} | {param_desc} |\n" - widget_list_msg += "\n" + widget_list_msg += format_widget(w) widget_list_msg += "\n" # Always show widget list and fetch last widget data on human messages From 14118fef3b73319e78de8904419e94d8e741ccd8 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 15:24:02 -0400 Subject: [PATCH 09/29] better --- .../vanilla_agent_dashboard_widgets/main.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index dc452e9..fa309e6 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -172,9 +172,19 @@ async def show_widgets_and_fetch(): if request.messages and request.messages[-1].role == "tool": # Extract widget name and data widget_name = "Unknown Widget" + widget_request_str = "" if all_widgets: last_widget = all_widgets[-1] widget_name = last_widget.name or last_widget.widget_id or 'Unnamed' + # Build the request string that was sent + widget_request_str = f"Widget: {widget_name}\n" + widget_request_str += f"Widget ID: {last_widget.widget_id}\n" + if last_widget.params: + widget_request_str += "Parameters sent:\n" + for p in last_widget.params: + current_val = getattr(p, 'current_value', None) + if current_val is not None: + widget_request_str += f" - {p.name}: {current_val}\n" # Extract data content data_content = "" @@ -188,7 +198,11 @@ async def show_widgets_and_fetch(): if data_content: sample = data_content[:500] + "..." if len(data_content) > 500 else data_content async def show_data_sample(): - yield message_chunk(f"Fetching sample data from last widget: {widget_name}\n\nSample of widget data:\n```\n{sample}\n```").model_dump() + msg = f"Fetching sample data from last widget: {widget_name}\n\n" + if widget_request_str: + msg += f"**Request sent to UI:**\n```\n{widget_request_str}```\n\n" + msg += f"**Sample of widget data returned:**\n```\n{sample}\n```" + yield message_chunk(msg).model_dump() return EventSourceResponse( content=show_data_sample(), From fcd503d0dfa02afffb1d8548813c768c39812075 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:19:29 -0400 Subject: [PATCH 10/29] better --- .../vanilla_agent_dashboard_widgets/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index fa309e6..682c0b5 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -45,12 +45,12 @@ async def query(request: QueryRequest) -> EventSourceResponse: # 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) - if request.widgets.extra: - all_widgets.extend(request.widgets.extra) # Helper function to format widget details def format_widget(w): @@ -88,7 +88,6 @@ def format_widget(w): msg += "\n" return msg - # Build widget list string with separation between explicit and dashboard context widget_list_msg = "" # Explicit Context (Primary) - widgets explicitly selected @@ -99,6 +98,8 @@ def 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 From c21cca325efbe338aa6463bfb5d33e7bc026061f Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:19:55 -0400 Subject: [PATCH 11/29] fix this folder --- .../tests/test_agent.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/tests/test_agent.py b/36-vanilla-agent-dashboard-widgets/tests/test_agent.py index 676a7d4..46af0cf 100644 --- a/36-vanilla-agent-dashboard-widgets/tests/test_agent.py +++ b/36-vanilla-agent-dashboard-widgets/tests/test_agent.py @@ -23,7 +23,7 @@ def reset_sse_starlette_appstatus_event(): AppStatus.should_exit_event = None -def test_agents_json_has_dashboard_search_feature(): +def test_agents_json_has_dashboard_search_feature_enabled(): response = test_client.get("/agents.json") assert response.status_code == 200 data = response.json() @@ -49,8 +49,8 @@ def test_query_recognizes_dashboard_widgets_from_secondary(): response = test_client.post("/v1/query", json=payload) assert response.status_code == 200 - # We expect a regular message listing dashboard widgets - CopilotResponse(response.text).has_any("copilotMessage", "Stock Price") + # We expect a regular message listing dashboard widgets (secondary only) + CopilotResponse(response.text).has_any("copilotMessage", "Company News") def test_query_respects_primary_selection_and_calls_get_widget_data(): @@ -67,8 +67,8 @@ def test_query_respects_primary_selection_and_calls_get_widget_data(): assert response.status_code == 200 # We expect a function call – let the UI handle the actual retrieval - CopilotResponse(response.text).starts("copilotFunctionCall").with_( - {"function": "get_widget_data"} + CopilotResponse(response.text).has_any( + "copilotFunctionCall", {"function": "get_widget_data"} ) @@ -87,4 +87,4 @@ def test_query_lists_dashboard_widgets(): response = test_client.post("/v1/query", json=payload) assert response.status_code == 200 - CopilotResponse(response.text).has_any("copilotMessage", "Stock Price") + CopilotResponse(response.text).has_any("copilotMessage", "Company News") From 06956c13b0d5f36ab163f4dc112f52407508eeca Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:20:11 -0400 Subject: [PATCH 12/29] improve readme --- README.md | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca76a0d..6d244a3 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,45 @@ 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 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 access widgets on the dashboard](./36-vanilla-agent-dashboard-widgets) + +CleanShot 2025-09-14 at 17 06 14@2x + +- [A vanilla agent that can improve a user prompt](./financial-prompt-optimizer) + +CleanShot 2025-09-14 at 17 35 36@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. From b01c3b9796f38d744cbd4be0f40c515c0fa24447 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:26:13 -0400 Subject: [PATCH 13/29] lint --- .../vanilla_agent_dashboard_widgets/main.py | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 682c0b5..23f1903 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -41,7 +41,7 @@ def get_copilot_description(): @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: @@ -51,7 +51,7 @@ async def query(request: QueryRequest) -> EventSourceResponse: # 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" @@ -62,53 +62,53 @@ def format_widget(w): 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') + 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() + 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] + '...' + 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 '' + 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] + '...' + 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: @@ -116,10 +116,10 @@ def format_widget(w): # 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): + if str(getattr(w, "uuid", "")) == str(widget.widget_uuid): full_widget = w break - + if full_widget: widget_list_msg += format_widget(full_widget) else: @@ -128,7 +128,9 @@ def format_widget(w): 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" + widget_list_msg += ( + f"| UUID | {widget.widget_uuid or 'N/A'} |\n\n" + ) else: widget_list_msg += " (no widgets)\n" widget_list_msg += "\n" @@ -141,13 +143,12 @@ def format_widget(w): # Always show widget list and fetch last widget data on human messages if request.messages[-1].role == "human": - # Stream widget list and then fetch last widget data async def show_widgets_and_fetch(): # Show the widget list (first message completes here) if widget_list_msg: yield message_chunk(widget_list_msg.rstrip()).model_dump() - + # Then fetch data from the last widget if available if all_widgets: last_widget = all_widgets[-1] @@ -155,14 +156,15 @@ async def show_widgets_and_fetch(): WidgetRequest( widget=last_widget, input_arguments={ - param.name: param.current_value for param in last_widget.params + param.name: param.current_value + for param in last_widget.params }, ) ] yield get_widget_data(widget_requests).model_dump() elif not widget_list_msg: yield message_chunk("No widgets found on your dashboard.").model_dump() - + # Return early with widget info and data fetch return EventSourceResponse( content=show_widgets_and_fetch(), @@ -176,17 +178,17 @@ async def show_widgets_and_fetch(): widget_request_str = "" if all_widgets: last_widget = all_widgets[-1] - widget_name = last_widget.name or last_widget.widget_id or 'Unnamed' + widget_name = last_widget.name or last_widget.widget_id or "Unnamed" # Build the request string that was sent widget_request_str = f"Widget: {widget_name}\n" widget_request_str += f"Widget ID: {last_widget.widget_id}\n" if last_widget.params: widget_request_str += "Parameters sent:\n" for p in last_widget.params: - current_val = getattr(p, 'current_value', None) + current_val = getattr(p, "current_value", None) if current_val is not None: widget_request_str += f" - {p.name}: {current_val}\n" - + # Extract data content data_content = "" for result in request.messages[-1].data: @@ -195,26 +197,26 @@ async def show_widgets_and_fetch(): break if data_content: break - + if data_content: - sample = data_content[:500] + "..." if len(data_content) > 500 else data_content + sample = ( + data_content[:500] + "..." if len(data_content) > 500 else data_content + ) + async def show_data_sample(): msg = f"Fetching sample data from last widget: {widget_name}\n\n" if widget_request_str: msg += f"**Request sent to UI:**\n```\n{widget_request_str}```\n\n" msg += f"**Sample of widget data returned:**\n```\n{sample}\n```" yield message_chunk(msg).model_dump() - + return EventSourceResponse( content=show_data_sample(), 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" - ) + + return EventSourceResponse(content=empty_response(), media_type="text/event-stream") From 92302187bf2a6308e0b474f339941788012765b5 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:39:20 -0400 Subject: [PATCH 14/29] fix desc --- .../vanilla_agent_dashboard_widgets/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 23f1903..57b4f66 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -58,7 +58,12 @@ def format_widget(w): msg += "| Field | Value |\n" msg += "|-------|-------|\n" msg += f"| Name | {w.name or 'N/A'} |\n" - msg += f"| Description | {w.description 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" From 0b7a3bc9a76aa4eafbde3c54885ed0d3547e00c7 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Mon, 15 Sep 2025 16:45:44 -0400 Subject: [PATCH 15/29] clean up --- .../vanilla_agent_dashboard_widgets/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index 57b4f66..fb88861 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -59,10 +59,10 @@ def format_widget(w): 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() + description = str(w.description or "N/A") + description = description.replace("\n", " ").replace(" ", " ").strip() if len(description) > 150: - description = description[:147] + '...' + 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" From a485b5a05581cadc354c16982edc01b5e5a86b39 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 15:59:16 -0400 Subject: [PATCH 16/29] 20 financial prompt optimizer --- .../README.md | 0 .../main.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {financial-prompt-optimizer => 20-financial-prompt-optimizer}/README.md (100%) rename {financial-prompt-optimizer => 20-financial-prompt-optimizer}/main.py (100%) diff --git a/financial-prompt-optimizer/README.md b/20-financial-prompt-optimizer/README.md similarity index 100% rename from financial-prompt-optimizer/README.md rename to 20-financial-prompt-optimizer/README.md diff --git a/financial-prompt-optimizer/main.py b/20-financial-prompt-optimizer/main.py similarity index 100% rename from financial-prompt-optimizer/main.py rename to 20-financial-prompt-optimizer/main.py From 6b46e09509c06d0816354eaf1591b7a565f72e52 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 15:59:28 -0400 Subject: [PATCH 17/29] remove extra yield from reasoning ex --- .../vanilla_agent_reasoning_steps/main.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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", From c28983509ceee897c4708f21a2e2323d68ba5804 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 16:13:28 -0400 Subject: [PATCH 18/29] Secondary --- .../vanilla_agent_raw_context/main.py | 4 ++++ 1 file changed, 4 insertions(+) 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( From 29dd7902d517282eb34a5143a8e7f9e7b498625d Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 16:24:06 -0400 Subject: [PATCH 19/29] 40 vanilla agent dashboards widgets --- .../README.md | 0 .../tests/__init__.py | 0 .../tests/test_agent.py | 0 .../__init__.py | 0 .../vanilla_agent_dashboard_widgets/main.py | 70 ++----------------- 5 files changed, 6 insertions(+), 64 deletions(-) rename {36-vanilla-agent-dashboard-widgets => 40-vanilla-agent-dashboard-widgets}/README.md (100%) rename {36-vanilla-agent-dashboard-widgets => 40-vanilla-agent-dashboard-widgets}/tests/__init__.py (100%) rename {36-vanilla-agent-dashboard-widgets => 40-vanilla-agent-dashboard-widgets}/tests/test_agent.py (100%) rename {36-vanilla-agent-dashboard-widgets => 40-vanilla-agent-dashboard-widgets}/vanilla_agent_dashboard_widgets/__init__.py (100%) rename {36-vanilla-agent-dashboard-widgets => 40-vanilla-agent-dashboard-widgets}/vanilla_agent_dashboard_widgets/main.py (71%) diff --git a/36-vanilla-agent-dashboard-widgets/README.md b/40-vanilla-agent-dashboard-widgets/README.md similarity index 100% rename from 36-vanilla-agent-dashboard-widgets/README.md rename to 40-vanilla-agent-dashboard-widgets/README.md diff --git a/36-vanilla-agent-dashboard-widgets/tests/__init__.py b/40-vanilla-agent-dashboard-widgets/tests/__init__.py similarity index 100% rename from 36-vanilla-agent-dashboard-widgets/tests/__init__.py rename to 40-vanilla-agent-dashboard-widgets/tests/__init__.py diff --git a/36-vanilla-agent-dashboard-widgets/tests/test_agent.py b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py similarity index 100% rename from 36-vanilla-agent-dashboard-widgets/tests/test_agent.py rename to 40-vanilla-agent-dashboard-widgets/tests/test_agent.py diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py similarity index 100% rename from 36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py rename to 40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py diff --git a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py similarity index 71% rename from 36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py rename to 40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py index fb88861..b7e8afe 100644 --- a/36-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -146,79 +146,21 @@ def format_widget(w): widget_list_msg += format_widget(w) widget_list_msg += "\n" - # Always show widget list and fetch last widget data on human messages + # Always show widget list on human messages if request.messages[-1].role == "human": - # Stream widget list and then fetch last widget data - async def show_widgets_and_fetch(): - # Show the widget list (first message completes here) + # Stream widget list only + async def show_widgets(): if widget_list_msg: yield message_chunk(widget_list_msg.rstrip()).model_dump() - - # Then fetch data from the last widget if available - if all_widgets: - last_widget = all_widgets[-1] - widget_requests = [ - WidgetRequest( - widget=last_widget, - input_arguments={ - param.name: param.current_value - for param in last_widget.params - }, - ) - ] - yield get_widget_data(widget_requests).model_dump() - elif not widget_list_msg: + else: yield message_chunk("No widgets found on your dashboard.").model_dump() - # Return early with widget info and data fetch + # Return early with widget info only return EventSourceResponse( - content=show_widgets_and_fetch(), + content=show_widgets(), media_type="text/event-stream", ) - # Check if we just received tool data - if so, show a sample and continue conversation - if request.messages and request.messages[-1].role == "tool": - # Extract widget name and data - widget_name = "Unknown Widget" - widget_request_str = "" - if all_widgets: - last_widget = all_widgets[-1] - widget_name = last_widget.name or last_widget.widget_id or "Unnamed" - # Build the request string that was sent - widget_request_str = f"Widget: {widget_name}\n" - widget_request_str += f"Widget ID: {last_widget.widget_id}\n" - if last_widget.params: - widget_request_str += "Parameters sent:\n" - for p in last_widget.params: - current_val = getattr(p, "current_value", None) - if current_val is not None: - widget_request_str += f" - {p.name}: {current_val}\n" - - # Extract data content - data_content = "" - for result in request.messages[-1].data: - for item in result.items: - data_content = item.content - break - if data_content: - break - - if data_content: - sample = ( - data_content[:500] + "..." if len(data_content) > 500 else data_content - ) - - async def show_data_sample(): - msg = f"Fetching sample data from last widget: {widget_name}\n\n" - if widget_request_str: - msg += f"**Request sent to UI:**\n```\n{widget_request_str}```\n\n" - msg += f"**Sample of widget data returned:**\n```\n{sample}\n```" - yield message_chunk(msg).model_dump() - - return EventSourceResponse( - content=show_data_sample(), - media_type="text/event-stream", - ) # If we reach here, no specific handler matched - return empty response async def empty_response(): From 49eef84a5224dce345bf4a3c18248e0ab69a41b4 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 16:31:59 -0400 Subject: [PATCH 20/29] cleaning --- 40-vanilla-agent-dashboard-widgets/README.md | 15 +++--- .../tests/test_agent.py | 53 ++++++++++++++----- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/40-vanilla-agent-dashboard-widgets/README.md b/40-vanilla-agent-dashboard-widgets/README.md index 383e8b3..27a95e1 100644 --- a/40-vanilla-agent-dashboard-widgets/README.md +++ b/40-vanilla-agent-dashboard-widgets/README.md @@ -1,13 +1,16 @@ -# 36 - Vanilla Agent Dashboard Widgets +# 40 - Vanilla Agent Dashboard Widgets -This example demonstrates a simple agent that receives the full list of widgets present on the current dashboard and passes that context directly to the LLM. The model decides which widget(s) to use and issues a function call accordingly. +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.). -- If the user has selected primary widgets, the agent immediately issues a function call to fetch data for them. -- Otherwise, the agent does not select widgets heuristically. It appends the full dashboard widget list to the prompt and instructs the LLM to respond with a `get_widget_data` JSON function call when needed. -- Falls back to a plain LLM reply if no data is needed. +- 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 @@ -18,4 +21,4 @@ Key behaviors: ## Test -- From this directory: `poetry run pytest tests` +- From this directory: `poetry run pytest tests` \ No newline at end of file diff --git a/40-vanilla-agent-dashboard-widgets/tests/test_agent.py b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py index 46af0cf..b476163 100644 --- a/40-vanilla-agent-dashboard-widgets/tests/test_agent.py +++ b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py @@ -32,7 +32,7 @@ def test_agents_json_has_dashboard_search_feature_enabled(): assert agent["features"]["widget-dashboard-search"] is True -def test_query_recognizes_dashboard_widgets_from_secondary(): +def test_query_lists_dashboard_widgets_from_secondary(): test_payload_path = ( Path(__file__).parent.parent.parent / "testing" @@ -43,17 +43,19 @@ def test_query_recognizes_dashboard_widgets_from_secondary(): # Simulate no explicit primary selection payload["widgets"]["primary"] = [] - # Ask to list widgets instead of retrieving; ensures non-LLM path is exercised + # 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 regular message listing dashboard widgets (secondary only) + # 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_respects_primary_selection_and_calls_get_widget_data(): +def test_query_lists_primary_widgets_when_selected(): # Use same payload but keep primary set test_payload_path = ( Path(__file__).parent.parent.parent @@ -66,14 +68,13 @@ def test_query_respects_primary_selection_and_calls_get_widget_data(): response = test_client.post("/v1/query", json=payload) assert response.status_code == 200 - # We expect a function call – let the UI handle the actual retrieval - CopilotResponse(response.text).has_any( - "copilotFunctionCall", {"function": "get_widget_data"} - ) + # 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_lists_dashboard_widgets(): - # Ask to list widgets and expect a direct message with names +def test_query_shows_widget_metadata_and_parameters(): test_payload_path = ( Path(__file__).parent.parent.parent / "testing" @@ -81,10 +82,36 @@ def test_query_lists_dashboard_widgets(): / "retrieve_widget_from_dashboard.json" ) payload = json.load(open(test_payload_path)) - payload["widgets"]["primary"] = [] - payload["messages"][0]["content"] = "What widgets are available in the dashboard?" + payload["messages"][0]["content"] = "Show me widget details" response = test_client.post("/v1/query", json=payload) assert response.status_code == 200 - CopilotResponse(response.text).has_any("copilotMessage", "Company News") + # 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") \ No newline at end of file From 29b9a1866bfa03f1747b33e48c02b82da806035c Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 16:38:52 -0400 Subject: [PATCH 21/29] ruff --- 40-vanilla-agent-dashboard-widgets/tests/test_agent.py | 6 +++--- .../vanilla_agent_dashboard_widgets/main.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/40-vanilla-agent-dashboard-widgets/tests/test_agent.py b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py index b476163..a327b51 100644 --- a/40-vanilla-agent-dashboard-widgets/tests/test_agent.py +++ b/40-vanilla-agent-dashboard-widgets/tests/test_agent.py @@ -107,11 +107,11 @@ def test_query_does_not_fetch_widget_data(): 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") \ No newline at end of file + CopilotResponse(response.text).has_any("copilotMessage") 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 index b7e8afe..bc3801a 100644 --- a/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -161,7 +161,6 @@ async def 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() From ca4a93ce93300293b0a507a992e711e0a68e9bf0 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Tue, 16 Sep 2025 16:42:21 -0400 Subject: [PATCH 22/29] ruff --- .../vanilla_agent_dashboard_widgets/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bc3801a..1b7902e 100644 --- a/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py +++ b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/main.py @@ -4,7 +4,7 @@ from sse_starlette.sse import EventSourceResponse from openbb_ai.models import QueryRequest -from openbb_ai import get_widget_data, WidgetRequest, message_chunk +from openbb_ai import message_chunk app = FastAPI() From 53de4c93b9805285c7dfcfe7788db42b887b48e5 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Wed, 17 Sep 2025 17:11:49 -0400 Subject: [PATCH 23/29] add vanilla pdf citations --- 36-vanilla-pdf-citations/README.md | 57 ++++ 36-vanilla-pdf-citations/tests/__init__.py | 0 36-vanilla-pdf-citations/tests/test_agent.py | 85 +++++ .../vanilla_agent_pdf_citations/__init__.py | 0 .../vanilla_agent_pdf_citations/main.py | 301 ++++++++++++++++++ 5 files changed, 443 insertions(+) create mode 100644 36-vanilla-pdf-citations/README.md create mode 100644 36-vanilla-pdf-citations/tests/__init__.py create mode 100644 36-vanilla-pdf-citations/tests/test_agent.py create mode 100644 36-vanilla-pdf-citations/vanilla_agent_pdf_citations/__init__.py create mode 100644 36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py diff --git a/36-vanilla-pdf-citations/README.md b/36-vanilla-pdf-citations/README.md new file mode 100644 index 0000000..a93d786 --- /dev/null +++ b/36-vanilla-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-pdf-citations/tests/__init__.py b/36-vanilla-pdf-citations/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/36-vanilla-pdf-citations/tests/test_agent.py b/36-vanilla-pdf-citations/tests/test_agent.py new file mode 100644 index 0000000..a3fb6e4 --- /dev/null +++ b/36-vanilla-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-pdf-citations/vanilla_agent_pdf_citations/__init__.py b/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py b/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py new file mode 100644 index 0000000..140cd3c --- /dev/null +++ b/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py @@ -0,0 +1,301 @@ +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 From e15032fb22cdc5ea09d8a85e25b72ded0cda98c6 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Wed, 17 Sep 2025 17:16:48 -0400 Subject: [PATCH 24/29] rename --- .../README.md | 0 .../tests/__init__.py | 0 .../tests/test_agent.py | 0 .../vanilla_agent_pdf_citations/__init__.py | 0 .../vanilla_agent_pdf_citations/main.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename {36-vanilla-pdf-citations => 36-vanilla-agent-pdf-citations}/README.md (100%) rename {36-vanilla-pdf-citations => 36-vanilla-agent-pdf-citations}/tests/__init__.py (100%) rename {36-vanilla-pdf-citations => 36-vanilla-agent-pdf-citations}/tests/test_agent.py (100%) rename {36-vanilla-pdf-citations => 36-vanilla-agent-pdf-citations}/vanilla_agent_pdf_citations/__init__.py (100%) rename {36-vanilla-pdf-citations => 36-vanilla-agent-pdf-citations}/vanilla_agent_pdf_citations/main.py (100%) diff --git a/36-vanilla-pdf-citations/README.md b/36-vanilla-agent-pdf-citations/README.md similarity index 100% rename from 36-vanilla-pdf-citations/README.md rename to 36-vanilla-agent-pdf-citations/README.md diff --git a/36-vanilla-pdf-citations/tests/__init__.py b/36-vanilla-agent-pdf-citations/tests/__init__.py similarity index 100% rename from 36-vanilla-pdf-citations/tests/__init__.py rename to 36-vanilla-agent-pdf-citations/tests/__init__.py diff --git a/36-vanilla-pdf-citations/tests/test_agent.py b/36-vanilla-agent-pdf-citations/tests/test_agent.py similarity index 100% rename from 36-vanilla-pdf-citations/tests/test_agent.py rename to 36-vanilla-agent-pdf-citations/tests/test_agent.py diff --git a/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/__init__.py b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/__init__.py similarity index 100% rename from 36-vanilla-pdf-citations/vanilla_agent_pdf_citations/__init__.py rename to 36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/__init__.py diff --git a/36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py similarity index 100% rename from 36-vanilla-pdf-citations/vanilla_agent_pdf_citations/main.py rename to 36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py From 69b2f439a60cba12e62357486a8b3dd4c0097045 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Wed, 17 Sep 2025 14:17:11 -0700 Subject: [PATCH 25/29] Remove duplicate example entry in README --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d244a3..017ce04 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ For documentation on how to use the OpenBB AI SDK (highly recommended!), see the ## 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: +- [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 @@ -35,12 +40,12 @@ If you prefer diving straight into code, we have a growing list of examples of c 35-pdf -- [A vanilla agent that can access widgets on the dashboard](./36-vanilla-agent-dashboard-widgets) +- [A vanilla agent that can handle PDF data and add citations in the document](./36-vanilla-agent-pdf-citations) -CleanShot 2025-09-14 at 17 06 14@2x +CleanShot 2025-09-17 at 17 10 39@2x -- [A vanilla agent that can improve a user prompt](./financial-prompt-optimizer) +- [A vanilla agent that can access widgets on the dashboard](./36-vanilla-agent-dashboard-widgets) -CleanShot 2025-09-14 at 17 35 36@2x +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. From c729438f1c4a3179ab6b0334312630371bd9425d Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Wed, 17 Sep 2025 14:17:28 -0700 Subject: [PATCH 26/29] Adjust image dimensions in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 017ce04..fefbf04 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ If you prefer diving straight into code, we have a growing list of examples of c - [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 +CleanShot 2025-09-17 at 17 10 39@2x - [A vanilla agent that can access widgets on the dashboard](./36-vanilla-agent-dashboard-widgets) From 422810d2ecf61f122e3cd2b45879b438802fa218 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Wed, 17 Sep 2025 17:22:58 -0400 Subject: [PATCH 27/29] ruff --- .../vanilla_agent_pdf_citations/main.py | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) 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 index 140cd3c..03dfd6a 100644 --- a/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py +++ b/36-vanilla-agent-pdf-citations/vanilla_agent_pdf_citations/main.py @@ -107,7 +107,9 @@ async def retrieve_widget_data() -> AsyncGenerator[FunctionCallSSE, None]: context_str = "" citations_list: list[Citation] = [] - pdf_text_positions: List[Dict[str, Any]] = [] # Store PDF text positions for 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( @@ -133,7 +135,8 @@ async def retrieve_widget_data() -> AsyncGenerator[FunctionCallSSE, None]: for widget_data_request in message.input_arguments["data_sources"]: # Find matching widget matching_widgets = [ - w for w in request.widgets.primary + w + for w in request.widgets.primary if str(w.uuid) == widget_data_request["widget_uuid"] ] @@ -148,7 +151,7 @@ async def retrieve_widget_data() -> AsyncGenerator[FunctionCallSSE, None]: 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: @@ -158,22 +161,24 @@ async def retrieve_widget_data() -> AsyncGenerator[FunctionCallSSE, None]: widget=widget, input_arguments=widget_data_request["input_args"], extra_details={ - "Page": first_line['page'], - "Reference": "First sentence of document" + "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'] - ) - ]] + 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) @@ -223,26 +228,28 @@ def extract_pdf_with_positions(pdf_bytes: bytes) -> Tuple[str, List[Dict[str, An # Group characters into lines lines = {} for char in page.chars: - y = round(char['top']) # Round to group by line + 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']) + 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() + 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 - }) + 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 @@ -254,19 +261,23 @@ def extract_pdf_with_positions(pdf_bytes: bytes) -> Tuple[str, List[Dict[str, An # Files can either be served from a URL... -async def _get_url_pdf_text(data: SingleFileReference) -> Tuple[str, List[Dict[str, Any]]]: +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]]]: +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] + data: list[DataContent | DataFileReferences], ) -> Tuple[str, List[Dict[str, Any]]]: """Process widget data and extract PDF text with positions if applicable. @@ -297,5 +308,5 @@ async def handle_widget_data( # string. result_str += f"{item.content}\n" result_str += "------\n" - + return result_str, all_positions From 16355d7575fb67407310c0da5858c442b23ef88f Mon Sep 17 00:00:00 2001 From: Theodore Aptekarev Date: Thu, 18 Sep 2025 14:07:27 +0300 Subject: [PATCH 28/29] Update readme files --- 20-financial-prompt-optimizer/README.md | 28 +++++++++----------- 40-vanilla-agent-dashboard-widgets/README.md | 2 +- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/20-financial-prompt-optimizer/README.md b/20-financial-prompt-optimizer/README.md index 68390b2..d749e94 100644 --- a/20-financial-prompt-optimizer/README.md +++ b/20-financial-prompt-optimizer/README.md @@ -17,36 +17,35 @@ Python, as well as your OpenAI API key. 1. Clone this repository to your local machine. -2. Set the OpenAI API key as an environment variable in your .bashrc or .zshrc file: +2. Install the necessary dependencies: - ``` sh - # in .zshrc or .bashrc - export OPENAI_API_KEY= + ```sh + poetry install --no-root ``` -3. Install the necessary dependencies: +3. Set the OpenAI API key as an environment variable: -``` sh -poetry install --no-root -``` + ```sh + export OPENAI_API_KEY= + ``` 4. Start the API server: -``` sh -cd financial-prompt-optimizer -poetry run uvicorn main:app --port 7777 --reload -``` + ```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 +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 +- 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` @@ -59,4 +58,3 @@ sections: - `Optimized Prompt: ` - `Rationale: <1–3 short bullets on what changed and why>` - diff --git a/40-vanilla-agent-dashboard-widgets/README.md b/40-vanilla-agent-dashboard-widgets/README.md index 27a95e1..2d262f9 100644 --- a/40-vanilla-agent-dashboard-widgets/README.md +++ b/40-vanilla-agent-dashboard-widgets/README.md @@ -21,4 +21,4 @@ Key behaviors: ## Test -- From this directory: `poetry run pytest tests` \ No newline at end of file +- From this directory: `poetry run pytest tests` From 44b70973aa089d6192b3c820213a00ef49d1e83c Mon Sep 17 00:00:00 2001 From: Theodore Aptekarev Date: Thu, 18 Sep 2025 14:09:39 +0300 Subject: [PATCH 29/29] Remove __all__ from the 40 agent __init__ --- .../vanilla_agent_dashboard_widgets/__init__.py | 1 - 1 file changed, 1 deletion(-) 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 index a9a2c5b..e69de29 100644 --- a/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py +++ b/40-vanilla-agent-dashboard-widgets/vanilla_agent_dashboard_widgets/__init__.py @@ -1 +0,0 @@ -__all__ = []