From fc497a4461303f2825ba98dcc7d923680bead80a Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 03:33:51 -0400 Subject: [PATCH 1/6] skills example --- 41-vanilla-agent-dynamic-skill/README.md | 132 ++++++ .../tests/__init__.py | 1 + .../tests/test_agent.py | 302 +++++++++++++ .../vanilla_agent_dynamic_skill/__init__.py | 1 + .../vanilla_agent_dynamic_skill/main.py | 426 ++++++++++++++++++ 5 files changed, 862 insertions(+) create mode 100644 41-vanilla-agent-dynamic-skill/README.md create mode 100644 41-vanilla-agent-dynamic-skill/tests/__init__.py create mode 100644 41-vanilla-agent-dynamic-skill/tests/test_agent.py create mode 100644 41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/__init__.py create mode 100644 41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py diff --git a/41-vanilla-agent-dynamic-skill/README.md b/41-vanilla-agent-dynamic-skill/README.md new file mode 100644 index 0000000..c3f413a --- /dev/null +++ b/41-vanilla-agent-dynamic-skill/README.md @@ -0,0 +1,132 @@ +# Vanilla Agent with Dynamic Skill Loading + +This example shows the smallest useful version of dynamic skill loading for an +OpenBB agent. + +It follows the same two-step handshake as the MCP example, but only for a +single skill-loading function: + +1. The model sees a lightweight `skills_catalog` +2. The model requests `get_skill_content` +3. The client/frontend loads the skill and sends it back as a tool result +4. The agent answers using the loaded skill instructions + +Unlike the MCP example, this agent does not do generic tool selection. It only +supports loading one skill and then answering. + +## Getting started + +### Prerequisites + +- Poetry +- `OPENAI_API_KEY` + +### Installation and running + +```sh +poetry install --no-root +cd 41-vanilla-agent-dynamic-skill +poetry run uvicorn vanilla_agent_dynamic_skill.main:app --host 127.0.0.1 --port 8003 --reload +``` + +Or run it directly: + +```sh +cd 41-vanilla-agent-dynamic-skill +poetry run python -m vanilla_agent_dynamic_skill.main +``` + +This example now defaults to `127.0.0.1:8003` when run as a module so it lines +up with a common local OpenBB agent URL. If your OpenBB custom agent is +configured to a different port, the server must be started on that same port. + +### Testing + +```sh +pytest tests +``` + +### Debugging local connectivity + +Check these URLs directly in your browser or with `curl`: + +```sh +curl http://127.0.0.1:8003/health +curl http://127.0.0.1:8003/agents.json +``` + +If OpenBB shows `ERR_CONNECTION_REFUSED`, it means the browser could not connect +to the server at all. The most common causes are: + +- the agent process is not running +- the agent is running on a different port than the one configured in OpenBB +- the process crashed before binding the port + +Once it is running, `/health` and `/agents.json` should respond directly. + +### Request shape + +Initial request with a skill catalog: + +```json +{ + "messages": [ + { + "role": "human", + "content": "Use the financial-analysis skill to review AAPL." + } + ], + "skills_catalog": [ + { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "updatedAt": "2026-03-22T12:00:00Z" + } + ] +} +``` + +Follow-up request after the client loads the skill: + +```json +{ + "messages": [ + { + "role": "human", + "content": "Use the financial-analysis skill to review AAPL." + }, + { + "role": "tool", + "function": "get_skill_content", + "input_arguments": { + "slug": "financial-analysis" + }, + "data": [ + { + "status": "success", + "data": { + "skill": { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "contentMarkdown": "# Financial Analysis\n\nFocus on revenue growth, margins, and guidance." + } + } + } + ] + } + ], + "skills_catalog": [ + { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "updatedAt": "2026-03-22T12:00:00Z" + } + ] +} +``` + +## Notes + +- This example intentionally supports only one loaded skill per request flow. +- If multiple `selected_skills` are provided, the first one is used. +- No widget, MCP, or other tool integrations are included. diff --git a/41-vanilla-agent-dynamic-skill/tests/__init__.py b/41-vanilla-agent-dynamic-skill/tests/__init__.py new file mode 100644 index 0000000..7bfa1a3 --- /dev/null +++ b/41-vanilla-agent-dynamic-skill/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the dynamic skill-loading example.""" diff --git a/41-vanilla-agent-dynamic-skill/tests/test_agent.py b/41-vanilla-agent-dynamic-skill/tests/test_agent.py new file mode 100644 index 0000000..eb60d38 --- /dev/null +++ b/41-vanilla-agent-dynamic-skill/tests/test_agent.py @@ -0,0 +1,302 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest +from fastapi.testclient import TestClient +from openbb_ai.testing import CopilotResponse + +from vanilla_agent_dynamic_skill.main import app + +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. + """ + from sse_starlette.sse import AppStatus + + AppStatus.should_exit_event = None + + +class MockStream: + def __init__(self, *chunks: str): + self._events = [ + SimpleNamespace( + choices=[SimpleNamespace(delta=SimpleNamespace(content=chunk))] + ) + for chunk in chunks + ] + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._events): + raise StopAsyncIteration + event = self._events[self._index] + self._index += 1 + return event + + +def _mock_streaming_client(*chunks: str): + async def mock_create(**kwargs): + assert kwargs["stream"] is True + return MockStream(*chunks) + + mock_client = mock.MagicMock() + mock_client.chat.completions.create = mock_create + return mock_client + + +def test_agents_json_configuration(): + response = test_client.get("/agents.json") + assert response.status_code == 200 + + data = response.json() + assert "vanilla_agent_dynamic_skill" in data + + config = data["vanilla_agent_dynamic_skill"] + assert config["name"] == "Vanilla Agent Dynamic Skill" + assert "loads one skill" in config["description"] + assert config["features"]["streaming"] is True + assert config["features"]["widget-dashboard-select"] is False + assert config["features"]["widget-dashboard-search"] is False + + +def test_query_streams_without_skills(): + test_payload_path = ( + Path(__file__).parent.parent.parent + / "testing" + / "test_payloads" + / "single_message.json" + ) + test_payload = json.load(open(test_payload_path)) + + with mock.patch("openai.AsyncOpenAI") as mock_openai: + mock_openai.return_value = _mock_streaming_client("Hello", " world") + + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + assert copilot_response.text == "Hello world" + + +def test_query_with_skill_catalog_emits_function_call(): + response_message = SimpleNamespace( + function_call=SimpleNamespace( + name="get_skill_content", + arguments=json.dumps( + { + "slug": "financial-analysis", + "reason": "The user asked for that skill by name.", + } + ), + ), + content=None, + ) + response_object = SimpleNamespace( + choices=[SimpleNamespace(message=response_message)] + ) + + async def mock_create(**kwargs): + assert kwargs["stream"] is False + assert kwargs["functions"][0]["name"] == "get_skill_content" + system_prompt = kwargs["messages"][0]["content"] + assert "financial-analysis" in system_prompt + assert "Only request one skill." in system_prompt + return response_object + + test_payload = { + "messages": [ + { + "role": "human", + "content": "Use the financial-analysis skill to review AAPL.", + } + ], + "skills_catalog": [ + { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "updatedAt": "2026-03-22T12:00:00Z", + } + ], + } + + with mock.patch("openai.AsyncOpenAI") as mock_openai: + mock_client = mock.MagicMock() + mock_client.chat.completions.create = mock_create + mock_openai.return_value = mock_client + + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + copilot_response.has_any( + "copilotFunctionCall", + { + "function": "get_skill_content", + "input_arguments": { + "slug": "financial-analysis", + "reason": "The user asked for that skill by name.", + }, + "extra_state": { + "copilot_function_call_arguments": { + "slug": "financial-analysis", + "reason": "The user asked for that skill by name.", + } + }, + }, + ) + + +def test_selected_skill_is_injected_and_no_function_is_exposed(): + async def mock_create(**kwargs): + assert kwargs["stream"] is True + assert "functions" not in kwargs + system_prompt = kwargs["messages"][0]["content"] + assert "## Active Skill" in system_prompt + assert "financial-analysis" in system_prompt + assert "Focus on revenue growth" in system_prompt + return MockStream("Loaded skill answer.") + + test_payload = { + "messages": [ + { + "role": "human", + "content": "Analyze AAPL with the selected skill.", + } + ], + "selected_skills": [ + { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "contentMarkdown": ( + "# Financial Analysis\n\nFocus on revenue growth, margins, and guidance." + ), + "source": "model_selected", + } + ], + "skills_catalog": [ + { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "updatedAt": "2026-03-22T12:00:00Z", + } + ], + } + + with mock.patch("openai.AsyncOpenAI") as mock_openai: + mock_client = mock.MagicMock() + mock_client.chat.completions.create = mock_create + mock_openai.return_value = mock_client + + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + assert copilot_response.text == "Loaded skill answer." + + +def test_tool_result_loads_skill_and_answers_without_second_function_call(): + async def mock_create(**kwargs): + assert kwargs["stream"] is True + assert "functions" not in kwargs + system_prompt = kwargs["messages"][0]["content"] + assert "## Active Skill" in system_prompt + assert "financial-analysis" in system_prompt + assert "Focus on margins" in system_prompt + return MockStream("Tool result answer.") + + test_payload = { + "messages": [ + { + "role": "human", + "content": "Analyze AAPL with the loaded skill.", + }, + { + "role": "tool", + "function": "get_skill_content", + "input_arguments": {"slug": "financial-analysis"}, + "data": [ + { + "status": "success", + "data": { + "skill": { + "slug": "financial-analysis", + "description": "Analyze company financials and earnings", + "contentMarkdown": ( + "# Financial Analysis\n\nFocus on margins and revenue growth." + ), + } + }, + } + ], + }, + ] + } + + with mock.patch("openai.AsyncOpenAI") as mock_openai: + mock_client = mock.MagicMock() + mock_client.chat.completions.create = mock_create + mock_openai.return_value = mock_client + + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + assert copilot_response.text == "Tool result answer." + + +def test_multiple_selected_skills_uses_the_first_one(): + async def mock_create(**kwargs): + system_prompt = kwargs["messages"][0]["content"] + assert "first-skill" in system_prompt + assert "Use the first skill." in system_prompt + assert "second-skill" not in system_prompt + return MockStream("First skill used.") + + test_payload = { + "messages": [ + { + "role": "human", + "content": "Use whichever skill is active.", + } + ], + "selected_skills": [ + { + "slug": "first-skill", + "description": "The first skill", + "contentMarkdown": "Use the first skill.", + "source": "model_selected", + }, + { + "slug": "second-skill", + "description": "The second skill", + "contentMarkdown": "Use the second skill.", + "source": "model_selected", + }, + ], + } + + with mock.patch("openai.AsyncOpenAI") as mock_openai: + mock_client = mock.MagicMock() + mock_client.chat.completions.create = mock_create + mock_openai.return_value = mock_client + + response = test_client.post("/v1/query", json=test_payload) + + assert response.status_code == 200 + copilot_response = CopilotResponse(response.text) + assert copilot_response.text == "First skill used." + + +def test_empty_messages_validation(): + response = test_client.post("/v1/query", json={"messages": []}) + assert "messages list cannot be empty" in response.text diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/__init__.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/__init__.py new file mode 100644 index 0000000..57b3bba --- /dev/null +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/__init__.py @@ -0,0 +1 @@ +"""Dynamic skill-loading example agent for OpenBB Workspace.""" diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py new file mode 100644 index 0000000..a865d58 --- /dev/null +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import json +import os +from typing import Any, AsyncGenerator, Literal + +import openai +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 message_chunk +from pydantic import BaseModel, Field, ValidationError, field_validator +from sse_starlette.sse import EventSourceResponse + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class SkillCatalogEntry(BaseModel): + slug: str + description: str + updated_at: str = Field(alias="updatedAt") + + +class SkillPayload(BaseModel): + slug: str + description: str + content_markdown: str = Field(alias="contentMarkdown") + source: Literal["forced_slash", "model_selected"] = "model_selected" + + +class HumanMessage(BaseModel): + role: Literal["human"] + content: str + + +class AiMessage(BaseModel): + role: Literal["ai"] + content: Any + + +class ToolResultPayload(BaseModel): + status: Literal["success", "error", "warning"] + message: str | None = None + data: dict[str, Any] | None = None + + +class ToolMessage(BaseModel): + role: Literal["tool"] + function: str + input_arguments: dict[str, Any] = Field(default_factory=dict) + data: list[ToolResultPayload] + extra_state: dict[str, Any] = Field(default_factory=dict) + + +ConversationMessage = HumanMessage | AiMessage | ToolMessage + + +class DynamicSkillQueryRequest(BaseModel): + messages: list[ConversationMessage] + skills_catalog: list[SkillCatalogEntry] | None = None + selected_skills: list[SkillPayload] | None = None + + @field_validator("messages") + @classmethod + def validate_messages(cls, messages: list[ConversationMessage]): + if not messages: + raise ValueError("messages list cannot be empty") + return messages + + +def _build_skill_function(skills_catalog: list[SkillCatalogEntry]) -> dict[str, Any]: + return { + "name": "get_skill_content", + "description": ( + "Load the full instructions for one skill from the available skills " + "catalog. Use this only when one listed skill is directly relevant " + "to the user's request." + ), + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The exact slug of the skill to load.", + "enum": [skill.slug for skill in skills_catalog], + }, + "reason": { + "type": "string", + "description": "A short explanation of why this skill is needed.", + }, + }, + "required": ["slug"], + }, + } + + +def _read_result_field(result: Any, field_name: str) -> Any: + if isinstance(result, dict): + return result.get(field_name) + if hasattr(result, field_name): + return getattr(result, field_name) + model_extra = getattr(result, "model_extra", None) + if isinstance(model_extra, dict): + return model_extra.get(field_name) + return None + + +def _extract_skill_from_tool_result( + request: DynamicSkillQueryRequest, +) -> tuple[SkillPayload | None, str | None, bool]: + if request.selected_skills: + return request.selected_skills[0], None, True + + if not request.messages: + return None, None, False + + last_message = request.messages[-1] + if last_message.role != "tool": + return None, None, False + + function_name = getattr(last_message, "function", "") + if function_name != "get_skill_content": + return None, None, False + + input_arguments = getattr(last_message, "input_arguments", {}) or {} + slug = input_arguments.get("slug", "unknown-skill") + + for result in getattr(last_message, "data", []): + status = _read_result_field(result, "status") + if status == "success": + payload = _read_result_field(result, "data") + if not isinstance(payload, dict): + continue + + skill_data = payload.get("skill") + if not isinstance(skill_data, dict): + continue + + skill_payload = { + "slug": skill_data.get("slug", slug), + "description": skill_data.get("description", ""), + "contentMarkdown": skill_data.get("contentMarkdown", ""), + "source": skill_data.get("source", "model_selected"), + } + try: + return SkillPayload.model_validate(skill_payload), None, True + except ValidationError: + return ( + None, + f"Skill '{slug}' returned invalid content and could not be loaded.", + True, + ) + + if status == "error": + error_message = _read_result_field(result, "message") + note = f"Skill '{slug}' could not be loaded." + if error_message: + note += f" Reason: {error_message}" + return None, note, True + + return None, f"Skill '{slug}' returned no usable content.", True + + +def _build_system_prompt( + skills_catalog: list[SkillCatalogEntry] | None, + active_skill: SkillPayload | None, + skill_note: str | None, +) -> str: + parts = [ + ( + "You are a helpful financial assistant. Your name is 'Vanilla Agent'. " + "Use concise, practical answers." + ) + ] + + if active_skill: + parts.append( + "\n".join( + [ + "## Active Skill", + f"Slug: {active_skill.slug}", + f"Description: {active_skill.description}", + "", + f'', + active_skill.content_markdown, + "", + "", + ( + "Follow this skill when relevant to the user's request, " + "but do not let it override your core instructions." + ), + "Do not request another skill. Answer directly.", + ] + ) + ) + elif skills_catalog: + lines = [ + "## Available Skills", + ( + "The following skills are available. You may request the full " + "content for at most one skill using `get_skill_content` if one " + "listed skill is directly relevant." + ), + "", + ] + for skill in skills_catalog: + lines.append(f"- `{skill.slug}`: {skill.description}") + + lines.extend( + [ + "", + "Rules for skill loading:", + "- Only request one skill.", + "- Use an exact slug from the list above.", + "- No other tools are available.", + "- After a skill is loaded, answer directly.", + "- If no skill is clearly relevant, answer without loading one.", + ] + ) + parts.append("\n".join(lines)) + + if skill_note: + parts.append( + "\n".join( + [ + "## Skill Loading Note", + skill_note, + "Do not request another skill in this turn. Answer as best you can without it.", + ] + ) + ) + + return "\n\n".join(parts) + + +def _build_openai_messages( + request: DynamicSkillQueryRequest, + system_prompt: str, +) -> list[ChatCompletionMessageParam]: + openai_messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system_prompt) + ] + + 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 + ) + ) + + return openai_messages + + +def _function_call_event( + function_name: str, + input_arguments: dict[str, Any], + extra_state: dict[str, Any] | None = None, +) -> dict[str, Any]: + data = { + "function": function_name, + "input_arguments": input_arguments, + } + if extra_state is not None: + data["extra_state"] = extra_state + return { + "event": "copilotFunctionCall", + "data": data, + } + + +@app.get("/agents.json") +def get_copilot_description(): + """Agent descriptor for OpenBB Workspace discovery.""" + return JSONResponse( + content={ + "vanilla_agent_dynamic_skill": { + "name": "Vanilla Agent Dynamic Skill", + "description": ( + "A minimal agent that dynamically loads one skill from the " + "client and then answers using those instructions." + ), + "image": ( + "https://github.com/OpenBB-finance/copilot-for-terminal-pro/" + "assets/14093308/7da2a512-93b9-478d-90bc-b8c3dd0cabcf" + ), + "endpoints": {"query": "/v1/query"}, + "features": { + "streaming": True, + "widget-dashboard-select": False, + "widget-dashboard-search": False, + }, + } + } + ) + + +@app.get("/health") +def health_check(): + """Simple health check for local debugging.""" + return JSONResponse( + content={ + "status": "ok", + "agent": "vanilla_agent_dynamic_skill", + "openai_api_key_configured": bool(os.getenv("OPENAI_API_KEY")), + } + ) + + +@app.post("/v1/query") +async def query(request: DynamicSkillQueryRequest) -> EventSourceResponse: + """Process a query with optional one-time dynamic skill loading.""" + + active_skill, skill_note, skill_request_completed = _extract_skill_from_tool_result( + request + ) + system_prompt = _build_system_prompt( + skills_catalog=request.skills_catalog, + active_skill=active_skill, + skill_note=skill_note, + ) + openai_messages = _build_openai_messages(request, system_prompt) + + allow_skill_loading = ( + bool(request.skills_catalog) + and active_skill is None + and not skill_request_completed + ) + functions = ( + [_build_skill_function(request.skills_catalog or [])] + if allow_skill_loading + else [] + ) + + async def execution_loop() -> AsyncGenerator[dict[str, Any], None]: + client = openai.AsyncOpenAI() + + if functions: + response = await client.chat.completions.create( + model="gpt-4.1", + messages=openai_messages, + functions=functions, + function_call="auto", + stream=False, + ) + message = response.choices[0].message + + if ( + getattr(message, "function_call", None) is not None + and message.function_call.name == "get_skill_content" + ): + try: + arguments = json.loads(message.function_call.arguments or "{}") + except json.JSONDecodeError: + yield message_chunk( + "I couldn't determine which skill to load." + ).model_dump(exclude_none=True) + return + + slug = arguments.get("slug") + if not slug: + yield message_chunk( + "I couldn't determine which skill to load." + ).model_dump(exclude_none=True) + return + + reason = arguments.get("reason") + input_arguments = {"slug": slug} + if reason: + input_arguments["reason"] = reason + + yield _function_call_event( + function_name="get_skill_content", + input_arguments=input_arguments, + extra_state={ + "copilot_function_call_arguments": input_arguments, + }, + ) + return + + content = getattr(message, "content", None) + if content: + yield message_chunk(content).model_dump(exclude_none=True) + return + + 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(exclude_none=True) + + return EventSourceResponse( + content=execution_loop(), + media_type="text/event-stream", + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "vanilla_agent_dynamic_skill.main:app", + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", "8003")), + reload=True, + ) From 1b8ef985802df4e557b3e2b802310a742d3b6104 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 03:51:21 -0400 Subject: [PATCH 2/6] skills example --- .../vanilla_agent_dynamic_skill/main.py | 34 +-- poetry.lock | 254 ++++++++++-------- pyproject.toml | 2 +- 3 files changed, 149 insertions(+), 141 deletions(-) diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py index a865d58..f946bce 100644 --- a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -15,6 +15,7 @@ ChatCompletionUserMessageParam, ) from openbb_ai import message_chunk +from openbb_ai.models import FunctionCallSSE, FunctionCallSSEData from pydantic import BaseModel, Field, ValidationError, field_validator from sse_starlette.sse import EventSourceResponse @@ -270,23 +271,6 @@ def _build_openai_messages( return openai_messages -def _function_call_event( - function_name: str, - input_arguments: dict[str, Any], - extra_state: dict[str, Any] | None = None, -) -> dict[str, Any]: - data = { - "function": function_name, - "input_arguments": input_arguments, - } - if extra_state is not None: - data["extra_state"] = extra_state - return { - "event": "copilotFunctionCall", - "data": data, - } - - @app.get("/agents.json") def get_copilot_description(): """Agent descriptor for OpenBB Workspace discovery.""" @@ -387,13 +371,15 @@ async def execution_loop() -> AsyncGenerator[dict[str, Any], None]: if reason: input_arguments["reason"] = reason - yield _function_call_event( - function_name="get_skill_content", - input_arguments=input_arguments, - extra_state={ - "copilot_function_call_arguments": input_arguments, - }, - ) + yield FunctionCallSSE( + data=FunctionCallSSEData( + function="get_skill_content", + input_arguments=input_arguments, + extra_state={ + "copilot_function_call_arguments": input_arguments, + }, + ) + ).model_dump(exclude_none=True) return content = getattr(message, "content", None) diff --git a/poetry.lock b/poetry.lock index 8c7b065..a8c252c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1520,18 +1520,18 @@ voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] [[package]] name = "openbb-ai" -version = "1.7.5" +version = "1.9.0" description = "An SDK for building agents compatible with OpenBB Workspace" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "openbb_ai-1.7.5-py3-none-any.whl", hash = "sha256:da1c919faccabc3f6fb49e9276fa7549c27435b642151ea6501b519e8260fa55"}, - {file = "openbb_ai-1.7.5.tar.gz", hash = "sha256:cb9056b4f3253dc8f841cdc1711454c12970e9ea336973d2ac385d7270bb163d"}, + {file = "openbb_ai-1.9.0-py3-none-any.whl", hash = "sha256:87127a033d77c870d0448c7f3e55fb73c6a7274f974dabc3d5c681d2525b22a2"}, + {file = "openbb_ai-1.9.0.tar.gz", hash = "sha256:17422639ebe482653d0be0b81bb464ebc611292daae2df8405f195086f0cc5b5"}, ] [package.dependencies] -pydantic = ">=2.11.4,<3.0.0" +pydantic = ">=2.12.0,<3.0.0" xxhash = ">=3.5.0,<4.0.0" [[package]] @@ -1887,21 +1887,21 @@ files = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2"}, - {file = "pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2"}, + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +pydantic-core = "2.41.5" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -1909,115 +1909,137 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, + {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, + {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, + {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, + {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, + {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, + {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, + {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, + {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, + {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, + {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, + {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, + {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, + {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, + {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, + {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, + {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, + {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, + {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, + {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, + {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, + {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" @@ -2775,14 +2797,14 @@ markers = {development = "python_version == \"3.10\""} [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -3180,4 +3202,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "0df242aa925c0c1a532bfe3e6232cf9e84d60a98962ae44f3cf2ea63fa858b93" +content-hash = "501fcd75dda54776e009aa0f9c6d4abb2a924abccd274c2a9ab490691dd76ada" diff --git a/pyproject.toml b/pyproject.toml index a0bc487..8ff017f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ pdfplumber = "^0.11.5" mypy = "^1.15.0" google-genai = "^1.11.0" asyncstdlib = "^3.13.1" -openbb-ai = "^1.7.5" +openbb-ai = "^1.9.0" httpx = "^0.28.1" From c4351bc126653ede38a2120911f65714d2c66f13 Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 04:03:44 -0400 Subject: [PATCH 3/6] simplify code --- .../vanilla_agent_dynamic_skill/main.py | 69 +++++-------------- 1 file changed, 17 insertions(+), 52 deletions(-) diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py index f946bce..28803e0 100644 --- a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -43,41 +43,14 @@ class SkillPayload(BaseModel): source: Literal["forced_slash", "model_selected"] = "model_selected" -class HumanMessage(BaseModel): - role: Literal["human"] - content: str - - -class AiMessage(BaseModel): - role: Literal["ai"] - content: Any - - -class ToolResultPayload(BaseModel): - status: Literal["success", "error", "warning"] - message: str | None = None - data: dict[str, Any] | None = None - - -class ToolMessage(BaseModel): - role: Literal["tool"] - function: str - input_arguments: dict[str, Any] = Field(default_factory=dict) - data: list[ToolResultPayload] - extra_state: dict[str, Any] = Field(default_factory=dict) - - -ConversationMessage = HumanMessage | AiMessage | ToolMessage - - class DynamicSkillQueryRequest(BaseModel): - messages: list[ConversationMessage] + messages: list[dict[str, Any]] skills_catalog: list[SkillCatalogEntry] | None = None selected_skills: list[SkillPayload] | None = None @field_validator("messages") @classmethod - def validate_messages(cls, messages: list[ConversationMessage]): + def validate_messages(cls, messages: list[dict[str, Any]]): if not messages: raise ValueError("messages list cannot be empty") return messages @@ -109,17 +82,6 @@ def _build_skill_function(skills_catalog: list[SkillCatalogEntry]) -> dict[str, } -def _read_result_field(result: Any, field_name: str) -> Any: - if isinstance(result, dict): - return result.get(field_name) - if hasattr(result, field_name): - return getattr(result, field_name) - model_extra = getattr(result, "model_extra", None) - if isinstance(model_extra, dict): - return model_extra.get(field_name) - return None - - def _extract_skill_from_tool_result( request: DynamicSkillQueryRequest, ) -> tuple[SkillPayload | None, str | None, bool]: @@ -130,20 +92,19 @@ def _extract_skill_from_tool_result( return None, None, False last_message = request.messages[-1] - if last_message.role != "tool": + if last_message.get("role") != "tool": return None, None, False - function_name = getattr(last_message, "function", "") - if function_name != "get_skill_content": + if last_message.get("function") != "get_skill_content": return None, None, False - input_arguments = getattr(last_message, "input_arguments", {}) or {} + input_arguments = last_message.get("input_arguments") or {} slug = input_arguments.get("slug", "unknown-skill") - for result in getattr(last_message, "data", []): - status = _read_result_field(result, "status") + for result in last_message.get("data", []): + status = result.get("status") if status == "success": - payload = _read_result_field(result, "data") + payload = result.get("data") if not isinstance(payload, dict): continue @@ -167,7 +128,7 @@ def _extract_skill_from_tool_result( ) if status == "error": - error_message = _read_result_field(result, "message") + error_message = result.get("message") note = f"Skill '{slug}' could not be loaded." if error_message: note += f" Reason: {error_message}" @@ -257,14 +218,18 @@ def _build_openai_messages( ] for message in request.messages: - if message.role == "human": + if message.get("role") == "human": openai_messages.append( - ChatCompletionUserMessageParam(role="user", content=message.content) + ChatCompletionUserMessageParam( + role="user", content=message["content"] + ) ) - elif message.role == "ai" and isinstance(message.content, str): + elif message.get("role") == "ai" and isinstance( + message.get("content"), str + ): openai_messages.append( ChatCompletionAssistantMessageParam( - role="assistant", content=message.content + role="assistant", content=message["content"] ) ) From ee093de2d0dcce5d7ed6b0cb34a1944d6465c17d Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 04:15:11 -0400 Subject: [PATCH 4/6] minimal code --- .../vanilla_agent_dynamic_skill/main.py | 189 +++++++----------- 1 file changed, 76 insertions(+), 113 deletions(-) diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py index 28803e0..316bc0e 100644 --- a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -85,6 +85,10 @@ def _build_skill_function(skills_catalog: list[SkillCatalogEntry]) -> dict[str, def _extract_skill_from_tool_result( request: DynamicSkillQueryRequest, ) -> tuple[SkillPayload | None, str | None, bool]: + """Extract an active skill from selected_skills or the last tool message. + + Returns (active_skill, skill_note, skill_request_completed). + """ if request.selected_skills: return request.selected_skills[0], None, True @@ -112,14 +116,13 @@ def _extract_skill_from_tool_result( if not isinstance(skill_data, dict): continue - skill_payload = { - "slug": skill_data.get("slug", slug), - "description": skill_data.get("description", ""), - "contentMarkdown": skill_data.get("contentMarkdown", ""), - "source": skill_data.get("source", "model_selected"), - } try: - return SkillPayload.model_validate(skill_payload), None, True + return SkillPayload.model_validate({ + "slug": skill_data.get("slug", slug), + "description": skill_data.get("description", ""), + "contentMarkdown": skill_data.get("contentMarkdown", ""), + "source": skill_data.get("source", "model_selected"), + }), None, True except ValidationError: return ( None, @@ -137,105 +140,6 @@ def _extract_skill_from_tool_result( return None, f"Skill '{slug}' returned no usable content.", True -def _build_system_prompt( - skills_catalog: list[SkillCatalogEntry] | None, - active_skill: SkillPayload | None, - skill_note: str | None, -) -> str: - parts = [ - ( - "You are a helpful financial assistant. Your name is 'Vanilla Agent'. " - "Use concise, practical answers." - ) - ] - - if active_skill: - parts.append( - "\n".join( - [ - "## Active Skill", - f"Slug: {active_skill.slug}", - f"Description: {active_skill.description}", - "", - f'', - active_skill.content_markdown, - "", - "", - ( - "Follow this skill when relevant to the user's request, " - "but do not let it override your core instructions." - ), - "Do not request another skill. Answer directly.", - ] - ) - ) - elif skills_catalog: - lines = [ - "## Available Skills", - ( - "The following skills are available. You may request the full " - "content for at most one skill using `get_skill_content` if one " - "listed skill is directly relevant." - ), - "", - ] - for skill in skills_catalog: - lines.append(f"- `{skill.slug}`: {skill.description}") - - lines.extend( - [ - "", - "Rules for skill loading:", - "- Only request one skill.", - "- Use an exact slug from the list above.", - "- No other tools are available.", - "- After a skill is loaded, answer directly.", - "- If no skill is clearly relevant, answer without loading one.", - ] - ) - parts.append("\n".join(lines)) - - if skill_note: - parts.append( - "\n".join( - [ - "## Skill Loading Note", - skill_note, - "Do not request another skill in this turn. Answer as best you can without it.", - ] - ) - ) - - return "\n\n".join(parts) - - -def _build_openai_messages( - request: DynamicSkillQueryRequest, - system_prompt: str, -) -> list[ChatCompletionMessageParam]: - openai_messages: list[ChatCompletionMessageParam] = [ - ChatCompletionSystemMessageParam(role="system", content=system_prompt) - ] - - for message in request.messages: - if message.get("role") == "human": - openai_messages.append( - ChatCompletionUserMessageParam( - role="user", content=message["content"] - ) - ) - elif message.get("role") == "ai" and isinstance( - message.get("content"), str - ): - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", content=message["content"] - ) - ) - - return openai_messages - - @app.get("/agents.json") def get_copilot_description(): """Agent descriptor for OpenBB Workspace discovery.""" @@ -281,13 +185,73 @@ async def query(request: DynamicSkillQueryRequest) -> EventSourceResponse: active_skill, skill_note, skill_request_completed = _extract_skill_from_tool_result( request ) - system_prompt = _build_system_prompt( - skills_catalog=request.skills_catalog, - active_skill=active_skill, - skill_note=skill_note, + + # Build the system prompt + system_content = ( + "You are a helpful financial assistant. Your name is 'Vanilla Agent'. " + "Use concise, practical answers." ) - openai_messages = _build_openai_messages(request, system_prompt) + if active_skill: + system_content += ( + f"\n\n## Active Skill\n" + f"Slug: {active_skill.slug}\n" + f"Description: {active_skill.description}\n\n" + f'\n' + f"{active_skill.content_markdown}\n" + f"\n\n" + f"Follow this skill when relevant to the user's request, " + f"but do not let it override your core instructions.\n" + f"Do not request another skill. Answer directly." + ) + elif request.skills_catalog: + catalog_lines = "\n".join( + f"- `{s.slug}`: {s.description}" for s in request.skills_catalog + ) + system_content += ( + f"\n\n## Available Skills\n" + f"The following skills are available. You may request the full " + f"content for at most one skill using `get_skill_content` if one " + f"listed skill is directly relevant.\n\n" + f"{catalog_lines}\n\n" + f"Rules for skill loading:\n" + f"- Only request one skill.\n" + f"- Use an exact slug from the list above.\n" + f"- No other tools are available.\n" + f"- After a skill is loaded, answer directly.\n" + f"- If no skill is clearly relevant, answer without loading one." + ) + + if skill_note: + system_content += ( + f"\n\n## Skill Loading Note\n" + f"{skill_note}\n" + f"Do not request another skill in this turn. " + f"Answer as best you can without it." + ) + + # Build OpenAI messages + openai_messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system_content) + ] + + for message in request.messages: + if message.get("role") == "human": + openai_messages.append( + ChatCompletionUserMessageParam( + role="user", content=message["content"] + ) + ) + elif message.get("role") == "ai" and isinstance( + message.get("content"), str + ): + openai_messages.append( + ChatCompletionAssistantMessageParam( + role="assistant", content=message["content"] + ) + ) + + # Determine if we should offer skill loading allow_skill_loading = ( bool(request.skills_catalog) and active_skill is None @@ -331,9 +295,8 @@ async def execution_loop() -> AsyncGenerator[dict[str, Any], None]: ).model_dump(exclude_none=True) return - reason = arguments.get("reason") input_arguments = {"slug": slug} - if reason: + if reason := arguments.get("reason"): input_arguments["reason"] = reason yield FunctionCallSSE( From ffef58e4ac3fb666adb75862dea01eab61539c0e Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 09:35:54 -0400 Subject: [PATCH 5/6] improvement --- .../vanilla_agent_dynamic_skill/main.py | 239 +++++++----------- 1 file changed, 87 insertions(+), 152 deletions(-) diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py index 316bc0e..1fb29a4 100644 --- a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -15,8 +15,8 @@ ChatCompletionUserMessageParam, ) from openbb_ai import message_chunk -from openbb_ai.models import FunctionCallSSE, FunctionCallSSEData -from pydantic import BaseModel, Field, ValidationError, field_validator +from openbb_ai.models import FunctionCallSSE, FunctionCallSSEData, QueryRequest +from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse app = FastAPI() @@ -43,101 +43,33 @@ class SkillPayload(BaseModel): source: Literal["forced_slash", "model_selected"] = "model_selected" -class DynamicSkillQueryRequest(BaseModel): - messages: list[dict[str, Any]] +class SkillQueryRequest(QueryRequest): skills_catalog: list[SkillCatalogEntry] | None = None selected_skills: list[SkillPayload] | None = None - @field_validator("messages") - @classmethod - def validate_messages(cls, messages: list[dict[str, Any]]): - if not messages: - raise ValueError("messages list cannot be empty") - return messages - - -def _build_skill_function(skills_catalog: list[SkillCatalogEntry]) -> dict[str, Any]: - return { - "name": "get_skill_content", - "description": ( - "Load the full instructions for one skill from the available skills " - "catalog. Use this only when one listed skill is directly relevant " - "to the user's request." - ), - "parameters": { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "The exact slug of the skill to load.", - "enum": [skill.slug for skill in skills_catalog], - }, - "reason": { - "type": "string", - "description": "A short explanation of why this skill is needed.", - }, - }, - "required": ["slug"], - }, - } - -def _extract_skill_from_tool_result( - request: DynamicSkillQueryRequest, -) -> tuple[SkillPayload | None, str | None, bool]: - """Extract an active skill from selected_skills or the last tool message. - - Returns (active_skill, skill_note, skill_request_completed). - """ +def _get_active_skill(request: SkillQueryRequest) -> SkillPayload | None: + """Return the active skill from selected_skills or the last tool message.""" if request.selected_skills: - return request.selected_skills[0], None, True - - if not request.messages: - return None, None, False - - last_message = request.messages[-1] - if last_message.get("role") != "tool": - return None, None, False - - if last_message.get("function") != "get_skill_content": - return None, None, False - - input_arguments = last_message.get("input_arguments") or {} - slug = input_arguments.get("slug", "unknown-skill") - - for result in last_message.get("data", []): - status = result.get("status") - if status == "success": - payload = result.get("data") - if not isinstance(payload, dict): - continue - - skill_data = payload.get("skill") - if not isinstance(skill_data, dict): - continue - - try: - return SkillPayload.model_validate({ - "slug": skill_data.get("slug", slug), - "description": skill_data.get("description", ""), - "contentMarkdown": skill_data.get("contentMarkdown", ""), - "source": skill_data.get("source", "model_selected"), - }), None, True - except ValidationError: - return ( - None, - f"Skill '{slug}' returned invalid content and could not be loaded.", - True, - ) - - if status == "error": - error_message = result.get("message") - note = f"Skill '{slug}' could not be loaded." - if error_message: - note += f" Reason: {error_message}" - return None, note, True - - return None, f"Skill '{slug}' returned no usable content.", True + return request.selected_skills[0] + + last = request.messages[-1] + if last.role != "tool" or getattr(last, "function", None) != "get_skill_content": + return None + + for result in getattr(last, "data", []): + if getattr(result, "status", None) != "success": + continue + payload = getattr(result, "data", None) + if isinstance(payload, dict) and isinstance(payload.get("skill"), dict): + skill = payload["skill"] + return SkillPayload.model_validate({ + "slug": skill.get("slug", ""), + "description": skill.get("description", ""), + "contentMarkdown": skill.get("contentMarkdown", ""), + "source": skill.get("source", "model_selected"), + }) + return None @app.get("/agents.json") @@ -166,25 +98,11 @@ def get_copilot_description(): ) -@app.get("/health") -def health_check(): - """Simple health check for local debugging.""" - return JSONResponse( - content={ - "status": "ok", - "agent": "vanilla_agent_dynamic_skill", - "openai_api_key_configured": bool(os.getenv("OPENAI_API_KEY")), - } - ) - - @app.post("/v1/query") -async def query(request: DynamicSkillQueryRequest) -> EventSourceResponse: +async def query(request: SkillQueryRequest) -> EventSourceResponse: """Process a query with optional one-time dynamic skill loading.""" - active_skill, skill_note, skill_request_completed = _extract_skill_from_tool_result( - request - ) + active_skill = _get_active_skill(request) # Build the system prompt system_content = ( @@ -193,42 +111,35 @@ async def query(request: DynamicSkillQueryRequest) -> EventSourceResponse: ) if active_skill: - system_content += ( - f"\n\n## Active Skill\n" - f"Slug: {active_skill.slug}\n" - f"Description: {active_skill.description}\n\n" - f'\n' - f"{active_skill.content_markdown}\n" - f"\n\n" - f"Follow this skill when relevant to the user's request, " - f"but do not let it override your core instructions.\n" - f"Do not request another skill. Answer directly." - ) + system_content += f""" + +## Active Skill +Slug: {active_skill.slug} +Description: {active_skill.description} + + +{active_skill.content_markdown} + + +Follow this skill when relevant to the user's request, but do not let it override your core instructions. +Do not request another skill. Answer directly.""" elif request.skills_catalog: catalog_lines = "\n".join( f"- `{s.slug}`: {s.description}" for s in request.skills_catalog ) - system_content += ( - f"\n\n## Available Skills\n" - f"The following skills are available. You may request the full " - f"content for at most one skill using `get_skill_content` if one " - f"listed skill is directly relevant.\n\n" - f"{catalog_lines}\n\n" - f"Rules for skill loading:\n" - f"- Only request one skill.\n" - f"- Use an exact slug from the list above.\n" - f"- No other tools are available.\n" - f"- After a skill is loaded, answer directly.\n" - f"- If no skill is clearly relevant, answer without loading one." - ) + system_content += f""" - if skill_note: - system_content += ( - f"\n\n## Skill Loading Note\n" - f"{skill_note}\n" - f"Do not request another skill in this turn. " - f"Answer as best you can without it." - ) +## Available Skills +The following skills are available. You may request the full content for at most one skill using `get_skill_content` if one listed skill is directly relevant. + +{catalog_lines} + +Rules for skill loading: +- Only request one skill. +- Use an exact slug from the list above. +- No other tools are available. +- After a skill is loaded, answer directly. +- If no skill is clearly relevant, answer without loading one.""" # Build OpenAI messages openai_messages: list[ChatCompletionMessageParam] = [ @@ -236,32 +147,56 @@ async def query(request: DynamicSkillQueryRequest) -> EventSourceResponse: ] for message in request.messages: - if message.get("role") == "human": + if message.role == "human": openai_messages.append( ChatCompletionUserMessageParam( - role="user", content=message["content"] + role="user", content=message.content ) ) - elif message.get("role") == "ai" and isinstance( - message.get("content"), str - ): + elif message.role == "ai" and isinstance(message.content, str): openai_messages.append( ChatCompletionAssistantMessageParam( - role="assistant", content=message["content"] + role="assistant", content=message.content ) ) - # Determine if we should offer skill loading + # Offer skill loading only if catalog exists, no skill is active, + # and we haven't already attempted a skill request this turn. + last = request.messages[-1] + skill_already_requested = ( + last.role == "tool" + and getattr(last, "function", None) == "get_skill_content" + ) allow_skill_loading = ( bool(request.skills_catalog) and active_skill is None - and not skill_request_completed - ) - functions = ( - [_build_skill_function(request.skills_catalog or [])] - if allow_skill_loading - else [] + and not skill_already_requested ) + functions = [] + if allow_skill_loading: + functions.append({ + "name": "get_skill_content", + "description": ( + "Load the full instructions for one skill from the available " + "skills catalog. Use this only when one listed skill is " + "directly relevant to the user's request." + ), + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The exact slug of the skill to load.", + "enum": [s.slug for s in request.skills_catalog or []], + }, + "reason": { + "type": "string", + "description": "A short explanation of why this skill is needed.", + }, + }, + "required": ["slug"], + }, + }) async def execution_loop() -> AsyncGenerator[dict[str, Any], None]: client = openai.AsyncOpenAI() From 9346324abbd82f5850e096010b0c8b6d3b43457f Mon Sep 17 00:00:00 2001 From: DidierRLopes Date: Sun, 22 Mar 2026 09:39:20 -0400 Subject: [PATCH 6/6] ruff --- .../vanilla_agent_dynamic_skill/main.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py index 1fb29a4..8d0f775 100644 --- a/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py +++ b/41-vanilla-agent-dynamic-skill/vanilla_agent_dynamic_skill/main.py @@ -63,12 +63,14 @@ def _get_active_skill(request: SkillQueryRequest) -> SkillPayload | None: payload = getattr(result, "data", None) if isinstance(payload, dict) and isinstance(payload.get("skill"), dict): skill = payload["skill"] - return SkillPayload.model_validate({ - "slug": skill.get("slug", ""), - "description": skill.get("description", ""), - "contentMarkdown": skill.get("contentMarkdown", ""), - "source": skill.get("source", "model_selected"), - }) + return SkillPayload.model_validate( + { + "slug": skill.get("slug", ""), + "description": skill.get("description", ""), + "contentMarkdown": skill.get("contentMarkdown", ""), + "source": skill.get("source", "model_selected"), + } + ) return None @@ -149,9 +151,7 @@ async def query(request: SkillQueryRequest) -> EventSourceResponse: for message in request.messages: if message.role == "human": openai_messages.append( - ChatCompletionUserMessageParam( - role="user", content=message.content - ) + ChatCompletionUserMessageParam(role="user", content=message.content) ) elif message.role == "ai" and isinstance(message.content, str): openai_messages.append( @@ -164,8 +164,7 @@ async def query(request: SkillQueryRequest) -> EventSourceResponse: # and we haven't already attempted a skill request this turn. last = request.messages[-1] skill_already_requested = ( - last.role == "tool" - and getattr(last, "function", None) == "get_skill_content" + last.role == "tool" and getattr(last, "function", None) == "get_skill_content" ) allow_skill_loading = ( bool(request.skills_catalog) @@ -174,29 +173,31 @@ async def query(request: SkillQueryRequest) -> EventSourceResponse: ) functions = [] if allow_skill_loading: - functions.append({ - "name": "get_skill_content", - "description": ( - "Load the full instructions for one skill from the available " - "skills catalog. Use this only when one listed skill is " - "directly relevant to the user's request." - ), - "parameters": { - "type": "object", - "properties": { - "slug": { - "type": "string", - "description": "The exact slug of the skill to load.", - "enum": [s.slug for s in request.skills_catalog or []], - }, - "reason": { - "type": "string", - "description": "A short explanation of why this skill is needed.", + functions.append( + { + "name": "get_skill_content", + "description": ( + "Load the full instructions for one skill from the available " + "skills catalog. Use this only when one listed skill is " + "directly relevant to the user's request." + ), + "parameters": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "The exact slug of the skill to load.", + "enum": [s.slug for s in request.skills_catalog or []], + }, + "reason": { + "type": "string", + "description": "A short explanation of why this skill is needed.", + }, }, + "required": ["slug"], }, - "required": ["slug"], - }, - }) + } + ) async def execution_loop() -> AsyncGenerator[dict[str, Any], None]: client = openai.AsyncOpenAI()