From ed0d262876d1520d7c445f1494e54662d23f48d5 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 08:45:05 -0400 Subject: [PATCH 01/13] initial commit --- Dockerfile | 27 + LICENSE | 2 +- README.md | 454 +++- agents/test-agent.md | 17 + opensensa.example.yaml | 53 + pyproject.toml | 87 + src/opensensa/__init__.py | 19 + src/opensensa/a2a/__init__.py | 27 + src/opensensa/a2a/agent_card.py | 78 + src/opensensa/a2a/client.py | 122 + src/opensensa/a2a/executor.py | 681 +++++ src/opensensa/a2a/task_store.py | 26 + src/opensensa/agents/__init__.py | 30 + src/opensensa/agents/agent-manager.md | 60 + src/opensensa/cli.py | 608 +++++ src/opensensa/config.py | 169 ++ src/opensensa/framework_tools/__init__.py | 24 + src/opensensa/framework_tools/create_agent.py | 108 + src/opensensa/framework_tools/delegate.py | 521 ++++ src/opensensa/framework_tools/delete_agent.py | 89 + .../framework_tools/discover_agents.py | 98 + src/opensensa/framework_tools/edit_agent.py | 122 + src/opensensa/framework_tools/list_tools.py | 63 + .../framework_tools/send_to_agent.py | 148 ++ src/opensensa/interactive/__init__.py | 17 + src/opensensa/interactive/call_graph.py | 533 ++++ src/opensensa/interactive/chat.py | 785 ++++++ src/opensensa/interactive/ui.py | 230 ++ src/opensensa/mcp_server/__init__.py | 17 + src/opensensa/mcp_server/server.py | 82 + src/opensensa/mcp_server/tool_loader.py | 95 + src/opensensa/orchestrator/__init__.py | 17 + src/opensensa/orchestrator/agent_builder.py | 154 ++ src/opensensa/orchestrator/agent_registry.py | 181 ++ src/opensensa/orchestrator/models.py | 94 + .../orchestrator/prompts/__init__.py | 17 + .../orchestrator/prompts/default.txt | 1 + src/opensensa/orchestrator/server.py | 209 ++ src/opensensa/orchestrator/tracing.py | 134 + src/opensensa/orchestrator/validators.py | 48 + src/opensensa/tools/__init__.py | 17 + src/opensensa/tools/add_numbers.py | 30 + src/opensensa/tools/csv_formatter.py | 97 + src/opensensa/tools/generate_visualization.py | 150 ++ src/opensensa/utils/__init__.py | 17 + src/opensensa/utils/logging.py | 259 ++ src/opensensa/web/__init__.py | 17 + src/opensensa/web/call_graph.py | 404 +++ src/opensensa/web/chat_manager.py | 321 +++ src/opensensa/web/routes.py | 332 +++ src/opensensa/web/static/app.js | 969 +++++++ src/opensensa/web/static/styles.css | 1354 ++++++++++ src/opensensa/web/templates/index.html | 70 + tests/__init__.py | 17 + tests/test_a2a_integration.py | 328 +++ tests/test_agent_registry.py | 121 + tests/test_call_graph.py | 523 ++++ tests/test_cli.py | 86 + tests/test_config.py | 102 + tests/test_framework_tools.py | 486 ++++ tests/test_tool_loader.py | 132 + uv.lock | 2231 +++++++++++++++++ 62 files changed, 14307 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 agents/test-agent.md create mode 100644 opensensa.example.yaml create mode 100644 pyproject.toml create mode 100644 src/opensensa/__init__.py create mode 100644 src/opensensa/a2a/__init__.py create mode 100644 src/opensensa/a2a/agent_card.py create mode 100644 src/opensensa/a2a/client.py create mode 100644 src/opensensa/a2a/executor.py create mode 100644 src/opensensa/a2a/task_store.py create mode 100644 src/opensensa/agents/__init__.py create mode 100644 src/opensensa/agents/agent-manager.md create mode 100644 src/opensensa/cli.py create mode 100644 src/opensensa/config.py create mode 100644 src/opensensa/framework_tools/__init__.py create mode 100644 src/opensensa/framework_tools/create_agent.py create mode 100644 src/opensensa/framework_tools/delegate.py create mode 100644 src/opensensa/framework_tools/delete_agent.py create mode 100644 src/opensensa/framework_tools/discover_agents.py create mode 100644 src/opensensa/framework_tools/edit_agent.py create mode 100644 src/opensensa/framework_tools/list_tools.py create mode 100644 src/opensensa/framework_tools/send_to_agent.py create mode 100644 src/opensensa/interactive/__init__.py create mode 100644 src/opensensa/interactive/call_graph.py create mode 100644 src/opensensa/interactive/chat.py create mode 100644 src/opensensa/interactive/ui.py create mode 100644 src/opensensa/mcp_server/__init__.py create mode 100644 src/opensensa/mcp_server/server.py create mode 100644 src/opensensa/mcp_server/tool_loader.py create mode 100644 src/opensensa/orchestrator/__init__.py create mode 100644 src/opensensa/orchestrator/agent_builder.py create mode 100644 src/opensensa/orchestrator/agent_registry.py create mode 100644 src/opensensa/orchestrator/models.py create mode 100644 src/opensensa/orchestrator/prompts/__init__.py create mode 100644 src/opensensa/orchestrator/prompts/default.txt create mode 100644 src/opensensa/orchestrator/server.py create mode 100644 src/opensensa/orchestrator/tracing.py create mode 100644 src/opensensa/orchestrator/validators.py create mode 100644 src/opensensa/tools/__init__.py create mode 100644 src/opensensa/tools/add_numbers.py create mode 100644 src/opensensa/tools/csv_formatter.py create mode 100644 src/opensensa/tools/generate_visualization.py create mode 100644 src/opensensa/utils/__init__.py create mode 100644 src/opensensa/utils/logging.py create mode 100644 src/opensensa/web/__init__.py create mode 100644 src/opensensa/web/call_graph.py create mode 100644 src/opensensa/web/chat_manager.py create mode 100644 src/opensensa/web/routes.py create mode 100644 src/opensensa/web/static/app.js create mode 100644 src/opensensa/web/static/styles.css create mode 100644 src/opensensa/web/templates/index.html create mode 100644 tests/__init__.py create mode 100644 tests/test_a2a_integration.py create mode 100644 tests/test_agent_registry.py create mode 100644 tests/test_call_graph.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_framework_tools.py create mode 100644 tests/test_tool_loader.py create mode 100644 uv.lock diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..743ee38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE ./ +COPY src/ src/ + +RUN pip install --no-cache-dir -e . + +# Default: run both servers +CMD ["opensensa", "serve"] diff --git a/LICENSE b/LICENSE index 261eeb9..64a232b 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright © 2025 CapsicoHealth Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 1e01b14..16330f3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,452 @@ -# OpenSensa -An agentic environment with confidential and regulatory environments in mind +

+ OpenSensa
+ Build multi-agent systems with markdown files, MCP tools, and the A2A protocol. +

+ +

+ PyPI + Python 3.10+ + MIT License +

+ +--- + +OpenSensa is an open-source CLI framework for building, running, and orchestrating AI agents. Define each agent as a single markdown file, wire up tools as plain Python functions, point at any OpenAI-compatible LLM, and get a production-ready multi-agent system with full [A2A protocol](https://google.github.io/A2A/) compliance and [MCP](https://modelcontextprotocol.io/) tool serving — in minutes, not weeks. + +## Why OpenSensa? + +Most agent frameworks require you to learn complex SDKs, write pages of boilerplate, and lock yourself into a specific LLM provider. OpenSensa takes a different approach: + +| Problem | OpenSensa's answer | +|---|---| +| **Agents are code-heavy to define** | Each agent is a **single `.md` file** — YAML frontmatter for config, markdown body for the system prompt. No Python classes, no inheritance hierarchies. | +| **Multi-agent communication is proprietary** | All agent-to-agent communication uses the **[A2A protocol](https://google.github.io/A2A/)** — the open standard for inter-agent messaging. Your agents are interoperable with Google ADK, LangGraph, CrewAI, or any A2A-compliant system. | +| **Tool integration is fragmented** | Tools are served over **[MCP](https://modelcontextprotocol.io/)** (Model Context Protocol). Write a Python function, decorate it, drop the file in a folder. Done. | +| **Locked to one LLM provider** | Works with **any OpenAI-compatible endpoint** — OpenAI, Ollama, vLLM, LM Studio, Together, Groq, Fireworks, and more. Switch models by editing one line in YAML. | +| **No observability out of the box** | Built-in **live call graph** in the terminal, **structured JSON logging**, and a **web UI** with real-time delegation tree visualization. | +| **Hard to go from prototype to production** | `opensensa chat` for development, `opensensa serve` for production. Same architecture, same agents, same tools. | + +## Key Features + +- **Agents as Markdown** — Each `.md` file with YAML frontmatter becomes a fully configured agent with an A2A endpoint, Agent Card, and SSE streaming support. +- **MCP for Tools, A2A for Agents** — Clean separation: tools use MCP, agent-to-agent communication uses A2A. Industry standards, not proprietary protocols. +- **Built-in Agent Manager** — Ships with a meta-agent that can create, edit, and delete other agents conversationally. Start a project, chat with the Agent Manager, and build your agent fleet without touching a file. +- **Explicit Delegation Graph** — Agents declare `sub_agents` in frontmatter. You can see exactly which agents can talk to which just by reading the `.md` files. Depth-limited (max 5 hops) to prevent infinite loops. +- **Live Call Graph** — Real-time Rich terminal tree showing tool calls (🔧), delegations (🤖), and LLM invocations (💬) with timing and token counts as they happen. +- **Web UI** — `opensensa serve --web` provides a browser-based chat interface with an agent sidebar, real-time delegation tree visualization, and agent CRUD. +- **Auto-Discovery** — Tools are auto-loaded from a directory; agents are scanned from the filesystem. Drop a file in, it's live — no restart needed. +- **Any LLM** — Single `OpenAIChatCompletionsModel` path works with any provider exposing `/v1/chat/completions`. +- **Structured Tracing** — Every LLM call, tool invocation, and delegation is logged as structured JSON with timing and token usage. + +## Quick Start + +### Installation + +```bash +pip install opensensa +``` + +Or install from source: + +```bash +git clone https://github.com/opensensa/opensensa.git +cd opensensa +pip install -e . +``` + +### Create a Project + +```bash +opensensa init my-project +cd my-project +``` + +This scaffolds: + +``` +my-project/ +├── opensensa.yaml # Model endpoints, server ports, directories +├── .env # API keys (gitignored) +├── agents/ +│ └── agent-manager.md # Built-in meta-agent for creating other agents +└── tools/ + ├── add_numbers.py # Example tools + ├── csv_formatter.py + └── generate_visualization.py +``` + +### Configure Your Model + +Edit `opensensa.yaml`: + +```yaml +models: + default: my-model + registry: + my-model: + base_url: https://api.openai.com/v1 + api_key: ${OPENAI_API_KEY} + model_name: gpt-4.1-mini +``` + +Set your API key: + +```bash +echo "OPENAI_API_KEY=sk-..." >> .env +``` + +
+Using a local model (Ollama, vLLM, LM Studio)? + +```yaml +models: + default: local-llama + registry: + local-llama: + base_url: http://localhost:11434/v1 # Ollama + api_key: none + model_name: llama3.3 +``` + +No API key needed — just point `base_url` at your local endpoint. + +
+ +### Start Chatting + +```bash +opensensa chat +``` + +This starts the MCP + A2A servers in the background, presents an agent picker, and drops you into an interactive Rich terminal session. All agents are live and can call each other over A2A. + +### Run as a Service + +```bash +opensensa serve +``` + +Headless mode — starts MCP tool server + A2A agent server. Each agent gets its own endpoint: + +``` +http://localhost:8000/agents/agent-manager/ # JSON-RPC endpoint +http://localhost:8000/agents/agent-manager/.well-known/agent-card.json # Agent Card +http://localhost:8000/agents/ # List all agents +``` + +Any A2A-compliant client can discover and talk to your agents. + +## Defining Agents + +Each agent is a `.md` file in your `agents/` directory: + +```markdown +--- +name: clinical-researcher +description: Analyzes clinical trial data and summarizes findings +model: gpt-4.1-mini +tools: + - csv_formatter + - generate_visualization +sub_agents: + - statistician + - data-analyst +skills: + - id: trial-analysis + name: Clinical Trial Analysis + description: Analyze clinical trial data including endpoints, outcomes, and methodology + tags: [clinical-trials, data-analysis] + examples: + - "Analyze the Phase III trial results for drug X" +input_modes: ["text/plain"] +output_modes: ["text/plain", "application/json"] +--- + +# Clinical Research Assistant + +You are a clinical research analyst. Your job is to help users understand +clinical trial data, outcomes, and study methodology. + +## Guidelines +- Always cite source data when making claims +- Use visualizations when presenting comparative data +- For statistical analysis, delegate to the statistician agent +``` + +**How it works:** +- The `---` YAML frontmatter configures the agent (name, model, tools, delegation targets, A2A skills) +- The markdown body becomes the system prompt sent to the LLM +- Frontmatter is *never* sent to the LLM — only the markdown body +- The agent automatically gets an A2A endpoint with an Agent Card generated from the frontmatter + +## Writing Tools + +Tools are plain Python files in your `tools/` directory. Any function decorated with `@mcp.tool()` is auto-registered: + +```python +# tools/search_papers.py +from fastmcp import FastMCP + +mcp = FastMCP() + +@mcp.tool() +def search_papers(query: str, max_results: int = 10) -> str: + """Search the medical literature for relevant papers. + + Args: + query: Search terms or research question + max_results: Maximum number of results to return + """ + # Your implementation here + return f"Found {max_results} papers for: {query}" +``` + +Or use the `register(mcp)` pattern for tools that need the server instance: + +```python +# tools/database_query.py +def register(mcp): + @mcp.tool() + def query_database(sql: str) -> str: + """Execute a read-only SQL query.""" + # ... + return results +``` + +Generate a tool skeleton: + +```bash +opensensa add-tool my_new_tool +``` + +## Multi-Agent Delegation + +Agents delegate to other agents via the A2A protocol. Declare delegation targets in frontmatter: + +```yaml +--- +name: project-manager +sub_agents: + - researcher + - writer + - reviewer +--- +``` + +When this agent runs, it can call the `delegate` tool to send tasks to its sub-agents: + +``` +User: "Write a blog post about transformer architectures" + +project-manager → delegate(agent_name="researcher", message="Find key papers on transformer architectures") + researcher processes and returns findings +project-manager → delegate(agent_name="writer", message="Write a blog post using these findings: ...") + writer produces draft +project-manager → delegate(agent_name="reviewer", message="Review this draft for accuracy: ...") + reviewer returns feedback +project-manager synthesizes everything and responds +``` + +Under the hood, every delegation is a real A2A `SendMessage` JSON-RPC call — the same protocol external agents would use. Delegation depth is capped at 5 hops to prevent infinite loops. + +## Framework Tools + +OpenSensa includes built-in MCP tools that are always available to agents: + +| Tool | Description | +|---|---| +| `delegate` | Delegate a task to a sub-agent by name (auto-enabled when `sub_agents` is declared) | +| `discover_agents` | Fetch A2A Agent Cards from local + remote agents | +| `send_to_agent` | Low-level A2A SendMessage to a specific URL (advanced) | +| `create_agent` | Create a new agent `.md` file | +| `edit_agent` | Modify an existing agent's configuration | +| `delete_agent` | Remove an agent (with confirmation) | +| `list_tools` | List all available MCP tools | + +## Configuration Reference + +Full `opensensa.yaml` options: + +```yaml +models: + default: my-model # Default model for agents that don't specify one + registry: + my-model: + base_url: https://api.openai.com/v1 + api_key: ${OPENAI_API_KEY} # Environment variable interpolation + model_name: gpt-4.1-mini + local-llama: + base_url: http://localhost:11434/v1 + api_key: none + model_name: llama3.3 + +server: + host: 0.0.0.0 + orchestrator_port: 8000 # A2A agent server + mcp_port: 8001 # MCP tool server + +agents: + directory: ./agents/ # Agent .md files + +tools: + directory: ./tools/ # Tool .py files + builtin: true # Include bundled example tools + +remote_agents: # External A2A agents to discover + - url: https://researcher.example.com + - url: http://localhost:9001 + +logging: + level: info # debug | info | warning | error + file: ./logs/opensensa.jsonl # Structured JSON log output +``` + +## CLI Reference + +| Command | Description | +|---|---| +| `opensensa init [dir]` | Scaffold a new project with config, agents, and sample tools | +| `opensensa chat [agent-name]` | Interactive Rich TUI — starts servers, opens a conversation | +| `opensensa serve` | Start MCP + A2A servers in headless mode | +| `opensensa serve --web` | Headless mode with browser-based web UI at `/web` | +| `opensensa add-tool ` | Generate a tool skeleton in `tools/` | +| `opensensa add-agent ` | Generate an agent skeleton in `agents/` | +| `opensensa list-tools` | List all registered MCP tools | +| `opensensa list-agents` | List all agents (local + remote) | +| `opensensa test` | Smoke test — validates config, agents, and tools | + +## Architecture + +OpenSensa uses a clean two-protocol architecture: + +``` +┌─────────────────────────────────────────────────────────┐ +│ OpenSensa Server │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Agent A │ │ Agent B │ │ Agent C │ │ +│ │ (A2A) │ │ (A2A) │ │ (A2A) │ │ +│ │ /agents/a/ │ │ /agents/b/ │ │ /agents/c/ │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ │ A2A SendMessage (JSON-RPC) │ │ +│ │◄──────────────►│◄──────────────►│ │ +│ │ │ │ │ +│ ┌──────┴────────────────┴────────────────┴──────┐ │ +│ │ MCP Tool Server │ │ +│ │ (user tools + framework tools) │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ + Any OpenAI-compatible LLM + (OpenAI, Ollama, vLLM, Groq, Together, ...) +``` + +- **Each agent** is its own A2A server with its own URL, Agent Card, executor, and task store +- **MCP** handles tool serving — tools are registered once, available to all agents +- **A2A** handles agent-to-agent communication — `delegate`, `discover_agents`, `send_to_agent` all speak A2A +- **OpenAI Agents SDK** runs the agent loop (prompt → LLM → tool calls → response) + +## Interoperability + +Because OpenSensa agents are A2A-compliant servers, they interoperate with any A2A-compatible system: + +- **Google ADK** agents can discover and call OpenSensa agents via Agent Cards +- **LangGraph**, **CrewAI**, or any A2A client can send `SendMessage` to your agents +- OpenSensa agents can call external A2A agents by adding them to `remote_agents` in config +- Agent Cards are served at the standard `/.well-known/agent-card.json` path + +## Web UI + +Run `opensensa serve --web` and open `http://localhost:8000/web`: + +- **Agent sidebar** — compact cards for all agents, click to start a chat +- **Real-time chat** — streaming responses with tool call animations +- **Delegation tree** — visual tree showing agent-to-agent calls as they happen, including nested delegation chains (A → B → C) +- **Agent CRUD** — create, edit, and delete agents from the browser + +## Development + +### Prerequisites + +- Python 3.10+ + +### Setup + +```bash +git clone https://github.com/opensensa/opensensa.git +cd opensensa +pip install -e ".[dev]" +``` + +### Running Tests + +```bash +pytest +``` + +### Linting + +```bash +ruff check src/ tests/ +``` + +### Project Structure + +``` +src/opensensa/ +├── cli.py # Click CLI commands +├── config.py # YAML config loader + Pydantic validation +├── a2a/ # A2A protocol layer +│ ├── agent_card.py # AgentCard builder from frontmatter +│ ├── executor.py # A2A → OpenAI Agents SDK bridge +│ ├── client.py # A2A client for outbound requests +│ └── task_store.py # In-memory task storage +├── orchestrator/ # Server + agent lifecycle +│ ├── server.py # Per-agent A2A sub-apps on FastAPI +│ ├── agent_builder.py # Agent construction + MCP wiring +│ ├── agent_registry.py # Filesystem agent discovery +│ ├── models.py # OpenAI-compatible model wrapper +│ └── tracing.py # Structured trace spans +├── mcp_server/ # MCP tool server +│ ├── server.py # FastMCP server +│ └── tool_loader.py # Auto-discovery from directory +├── framework_tools/ # Built-in MCP tools +│ ├── delegate.py # Agent-to-agent delegation +│ ├── discover_agents.py # A2A Agent Card fetching +│ ├── create_agent.py # Agent CRUD +│ └── ... +├── interactive/ # Rich TUI for `opensensa chat` +│ ├── chat.py # Chat session + RunHooks +│ ├── ui.py # Rich components +│ └── call_graph.py # Live call-graph tree +└── web/ # Browser UI + ├── routes.py # FastAPI routes + ├── chat_manager.py # Session management + ├── static/ # JS + CSS + └── templates/ # HTML +``` + +## Roadmap + +- [ ] `opensensa deploy` — single-command cloud deployment +- [ ] Persistent task store (SQLite / PostgreSQL) +- [ ] Agent-level authentication and API keys +- [ ] Trace viewer UI +- [ ] Plugin system for custom executors +- [ ] Windows and macOS testing + +## Contributing + +Contributions are welcome! Please open an issue to discuss what you'd like to change before submitting a PR. + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/my-feature`) +3. Make your changes and add tests +4. Run `pytest` and `ruff check src/ tests/` +5. Submit a pull request + +## License + +[Apache 2.0](LICENSE) — © 2025 OpenSensa Team diff --git a/agents/test-agent.md b/agents/test-agent.md new file mode 100644 index 0000000..f019031 --- /dev/null +++ b/agents/test-agent.md @@ -0,0 +1,17 @@ +--- +name: test-agent +description: A simple test agent +model: ${default} +tools: [] +skills: + - id: test-skill + name: Test Skill + description: Just a test + tags: [test] +input_modes: ["text/plain"] +output_modes: ["text/plain"] +--- + +# Test Agent + +You are a test agent. diff --git a/opensensa.example.yaml b/opensensa.example.yaml new file mode 100644 index 0000000..5e61151 --- /dev/null +++ b/opensensa.example.yaml @@ -0,0 +1,53 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +# OpenSensa Configuration +# Copy this to opensensa.yaml and customize for your project. + +models: + # The default model used when an agent specifies model: ${default} or omits model + default: my-model + registry: + my-model: + base_url: https://api.openai.com/v1 # Any OpenAI-compatible endpoint + api_key: ${OPENAI_API_KEY} # Environment variable interpolation + model_name: gpt-4.1-mini + # Example: local Ollama + # local-llama: + # base_url: http://localhost:11434/v1 + # api_key: none + # model_name: llama3.3 + +server: + host: 0.0.0.0 + orchestrator_port: 8000 # A2A agent server + mcp_port: 8001 # MCP tool server + +agents: + directory: ./agents/ # Where agent .md files live + +tools: + directory: ./tools/ # Where tool .py files live + builtin: true # Include bundled example tools + +# Remote A2A agents to discover +# remote_agents: +# - url: https://researcher-service.example.com +# - url: http://localhost:9001 + +logging: + level: info # debug, info, warning, error + # file: ./logs/opensensa.jsonl # Optional: structured JSON log file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2283d83 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opensensa" +version = "0.1.0" +description = "A CLI framework for building AI agents with A2A protocol and MCP tools" +readme = "README.md" +license = {file = "LICENSE"} +requires-python = ">=3.10" +authors = [ + { name = "Capsico Team" }, +] +keywords = ["ai", "agents", "a2a", "mcp", "llm", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "fastmcp>=2.13.1", + "mcp[cli]>=1.14.1", + "openai>=1.0.0", + "openai-agents>=0.0.7", + "a2a-sdk>=0.3.0", + "httpx>=0.28.1", + "click>=8.1.0", + "pyyaml>=6.0", + "pydantic>=2.0.0", + "python-dotenv>=1.0.0", + "uvicorn[standard]>=0.30.0", + "fastapi>=0.115.0", + "python-frontmatter>=1.1.0", + "uuid7>=0.1.0", + "rich>=13.0.0", + "prompt-toolkit>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.8.0", +] + +[project.scripts] +opensensa = "opensensa.cli:cli" + +[tool.hatch.build.targets.wheel] +packages = ["src/opensensa"] + +[tool.hatch.build.targets.wheel.force-include] +"src/opensensa/web/static" = "opensensa/web/static" +"src/opensensa/web/templates" = "opensensa/web/templates" + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/src/opensensa/__init__.py b/src/opensensa/__init__.py new file mode 100644 index 0000000..8282567 --- /dev/null +++ b/src/opensensa/__init__.py @@ -0,0 +1,19 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""OpenSensa — AI agent framework with A2A protocol and MCP tools.""" + +__version__ = "0.1.0" diff --git a/src/opensensa/a2a/__init__.py b/src/opensensa/a2a/__init__.py new file mode 100644 index 0000000..a1b6119 --- /dev/null +++ b/src/opensensa/a2a/__init__.py @@ -0,0 +1,27 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""A2A protocol layer — Agent Cards, executor, client, task store.""" + +from opensensa.a2a.executor import FrameworkAgentExecutor +from opensensa.a2a.agent_card import build_agent_card +from opensensa.a2a.task_store import InMemoryTaskStore + +__all__ = [ + "FrameworkAgentExecutor", + "build_agent_card", + "InMemoryTaskStore", +] diff --git a/src/opensensa/a2a/agent_card.py b/src/opensensa/a2a/agent_card.py new file mode 100644 index 0000000..9955a69 --- /dev/null +++ b/src/opensensa/a2a/agent_card.py @@ -0,0 +1,78 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""AgentCard builder — creates A2A Agent Cards from agent .md frontmatter. + +Uses the a2a-sdk AgentCard Pydantic model for type-safe card construction. +Each agent gets its own AgentCard with a unique URL (per A2A spec). +""" + +from a2a.types import ( + AgentCard, + AgentCapabilities, + AgentSkill as A2ASkill, +) + +from opensensa.orchestrator.agent_registry import AgentDefinition + + +def build_agent_card(agent: AgentDefinition, base_url: str) -> AgentCard: + """Build an A2A AgentCard for a single agent. + + The card's ``url`` is set to ``{base_url}/agents/{agent.name}`` so that + the agent's JSON-RPC endpoint lives at its own sub-app path. + + Args: + agent: Parsed agent definition from a .md file. + base_url: The server base URL (e.g. http://localhost:8000). + + Returns: + A2A AgentCard (Pydantic model from a2a-sdk). + """ + skills = [] + for s in agent.skills: + skill = A2ASkill( + id=s.id, + name=s.name, + description=s.description, + tags=s.tags or [], + examples=s.examples if s.examples else None, + ) + skills.append(skill) + + # If no skills defined, create a default one from the agent itself + if not skills: + skills.append(A2ASkill( + id=agent.name, + name=agent.name, + description=agent.description, + tags=[], + )) + + return AgentCard( + name=agent.name, + description=agent.description, + url=f"{base_url.rstrip('/')}/agents/{agent.name}", + version="1.0.0", + capabilities=AgentCapabilities( + streaming=True, + push_notifications=False, + state_transition_history=False, + ), + default_input_modes=agent.input_modes, + default_output_modes=agent.output_modes, + skills=skills, + ) diff --git a/src/opensensa/a2a/client.py b/src/opensensa/a2a/client.py new file mode 100644 index 0000000..6218bca --- /dev/null +++ b/src/opensensa/a2a/client.py @@ -0,0 +1,122 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""A2A client — sends SendMessage to remote agents. + +Used by the send_to_agent framework tool. Supports both raw httpx calls +and (when available) the a2a-sdk client for richer type-safety. +""" + +import logging +import uuid +from typing import Any, Optional + +import httpx + +from a2a.types import ( + AgentCard, + Message, + MessageSendParams, + Role, + SendMessageRequest, + TextPart, +) + +logger = logging.getLogger("opensensa.a2a") + + +async def fetch_agent_card(agent_url: str) -> AgentCard: + """Fetch an A2A Agent Card from a remote agent's well-known URL. + + Args: + agent_url: Base URL of the agent (e.g. http://localhost:9000). + + Returns: + The Agent Card as an a2a-sdk AgentCard model. + """ + url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url) + resp.raise_for_status() + return AgentCard.model_validate(resp.json()) + + +async def fetch_agent_card_dict(agent_url: str) -> dict[str, Any]: + """Fetch an A2A Agent Card as a raw dict (for framework tools). + + Args: + agent_url: Base URL of the agent. + + Returns: + The Agent Card as a dict. + """ + url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.json() + + +async def send_message( + agent_url: str, + message: str, + task_id: Optional[str] = None, + context_id: Optional[str] = None, + timeout: float = 120.0, + metadata: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Send an A2A SendMessage JSON-RPC request to a remote agent. + + Args: + agent_url: Base URL of the agent. + message: Text message to send. + task_id: Optional task ID for multi-turn. + context_id: Optional context ID for conversation threading. + timeout: Request timeout in seconds. + metadata: Optional metadata to include in the request. + + Returns: + JSON-RPC result dict. + """ + # POST to the agent's root — the JSON-RPC endpoint for per-agent sub-apps + url = f"{agent_url.rstrip('/')}/" + + msg_id = str(uuid.uuid4()) + a2a_message: dict[str, Any] = { + "role": "user", + "parts": [{"kind": "text", "text": message}], + "messageId": msg_id, + } + if task_id: + a2a_message["taskId"] = task_id + if context_id: + a2a_message["contextId"] = context_id + + params: dict[str, Any] = {"message": a2a_message} + if metadata: + params["metadata"] = metadata + + payload = { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "message/send", + "params": params, + } + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"}) + resp.raise_for_status() + return resp.json() diff --git a/src/opensensa/a2a/executor.py b/src/opensensa/a2a/executor.py new file mode 100644 index 0000000..1ce9f20 --- /dev/null +++ b/src/opensensa/a2a/executor.py @@ -0,0 +1,681 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""FrameworkAgentExecutor — bridges A2A protocol requests to OpenAI Agents SDK execution. + +Implements the a2a-sdk AgentExecutor interface. Each executor instance is +bound to exactly one agent (1 agent = 1 URL = 1 executor). When an A2A +SendMessage arrives on that agent's sub-app, this executor: + 1. Extracts the user message from the RequestContext + 2. Builds an OpenAI Agents SDK Agent with MCP tools + 3. Runs the agent via Runner.run() (non-streaming) or Runner.run_streamed() + 4. Publishes results into the EventQueue as A2A events + +For ``message/send`` the executor uses ``Runner.run()`` and returns the +final result. For ``message/stream`` it uses ``Runner.run_streamed()`` +and publishes incremental ``TaskStatus`` / ``TaskArtifactUpdate`` events +via SSE as the LLM generates tokens. +""" + +import asyncio +import json as _json +import logging +import uuid as _uuid +from contextlib import AsyncExitStack +from typing import Any, Optional + +from a2a.server.agent_execution.agent_executor import AgentExecutor +from a2a.server.agent_execution.context import RequestContext +from a2a.server.events.event_queue import EventQueue +from a2a.server.tasks.task_updater import TaskUpdater +from a2a.types import ( + DataPart, + TaskState, + TextPart, +) + +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentRegistry +from opensensa.orchestrator.tracing import AgentTraceContext + +logger = logging.getLogger("opensensa.a2a") + +# Header used to propagate current delegation depth across agent hops +_DEPTH_HEADER = "X-A2A-Depth" + +# Header used to propagate the original client JSON-RPC id across all hops +_CLIENT_ID_HEADER = "X-A2A-Client-Request-Id" + + +def _build_event_metadata(event: dict) -> dict[str, Any]: + """Build ``framework:*`` metadata dict from an internal ``__event__`` dict. + + Maps ``{"__event__": "tool_start", "tool": "search"}`` → + ``{"framework:event": "tool_start", "framework:tool": "search"}``. + + This metadata is attached to A2A ``Message.metadata`` on status-update + messages, giving clients a structured way to parse rich events without + having to JSON-parse the text field. + """ + return { + f"framework:{k.strip('_')}": v + for k, v in event.items() + if v is not None + } + + +# --------------------------------------------------------------------------- +# A2ACallGraphAdapter — emits delegation events into the A2A SSE stream +# --------------------------------------------------------------------------- + +class A2ACallGraphAdapter: + """Lightweight call-graph adapter for use inside A2A executors. + + Implements the same synchronous interface as ``WebCallGraph`` + (``start_delegation``, ``end_delegation``, ``update_delegation_target``, + ``add_delegation_sub_event``) but instead of pushing events to a browser + SSE queue, it publishes ``__event__`` JSON payloads as A2A + ``status-update`` messages via the ``TaskUpdater``. + + This enables **nested delegation visibility**: when Agent B (invoked + from A) delegates to Agent C, the ``delegation_start`` / + ``delegation_end`` events appear in B's SSE stream. Agent A's + ``_try_stream_delegation()`` parses them and forwards to A's own + ``call_graph`` — which may be a ``WebCallGraph`` pushing to the + browser, creating a recursive forwarding chain. + + The adapter exposes **async** ``emit_*`` methods that callers should + ``await`` when possible (guaranteeing delivery). The sync + ``start_delegation`` / ``end_delegation`` methods are kept as + fire-and-forget fallbacks for call-sites that cannot ``await``. + """ + + def __init__(self, updater: "TaskUpdater", agent_name: str) -> None: + self._updater = updater + self._agent_name = agent_name + + # -- Core async emit (guaranteed delivery) -------------------------------- + + async def _emit_async(self, event: dict) -> None: + """Emit a structured JSON event as an A2A status-update message. + + Event data is delivered via ``message.metadata`` using ``framework:*`` + keys. The text part is left empty — clients should read metadata, + not parse parts text. + """ + etype = event.get("__event__", "unknown") + logger.info(f"A2ACallGraphAdapter[{self._agent_name}]: emitting {etype}") + try: + metadata = _build_event_metadata(event) + msg = self._updater.new_agent_message( + parts=[TextPart(kind="text", text="")], + metadata=metadata, + ) + + await self._updater.update_status( + state=TaskState.working, + message=msg, + ) + logger.info(f"A2ACallGraphAdapter[{self._agent_name}]: emitted {etype} OK") + except Exception as exc: + logger.warning(f"A2ACallGraphAdapter[{self._agent_name}]: failed to emit {etype}: {exc}") + + # -- Async methods (preferred — callers should await these) --------------- + + async def emit_delegation_start( + self, from_agent: str, to_agent: str, *, message: str = "" + ) -> None: + """Await-able emit of ``delegation_start`` into the A2A SSE stream.""" + await self._emit_async({ + "__event__": "delegation_start", + "from_agent": from_agent, + "to_agent": to_agent, + "message": message, + }) + + async def emit_delegation_end( + self, to_agent: str, *, response: str = "" + ) -> None: + """Await-able emit of ``delegation_end`` into the A2A SSE stream.""" + await self._emit_async({ + "__event__": "delegation_end", + "to_agent": to_agent, + "response": response, + }) + + # -- Sync fallbacks (fire-and-forget, used when caller cannot await) ------ + + def _fire_and_forget(self, event: dict) -> None: + """Schedule an async emit — best-effort, no delivery guarantee.""" + try: + loop = asyncio.get_running_loop() + loop.create_task(self._emit_async(event)) + except RuntimeError: + logger.debug("No running event loop — cannot emit delegation event") + + def start_delegation( + self, from_agent: str, to_agent: str, *, message: str = "" + ) -> None: + """Sync fallback — prefer ``emit_delegation_start`` when possible.""" + self._fire_and_forget({ + "__event__": "delegation_start", + "from_agent": from_agent, + "to_agent": to_agent, + "message": message, + }) + + def end_delegation( + self, to_agent: str, *, response: str = "", duration_ms: int = 0 + ) -> None: + """Sync fallback — prefer ``emit_delegation_end`` when possible.""" + self._fire_and_forget({ + "__event__": "delegation_end", + "to_agent": to_agent, + "response": response, + }) + + def update_delegation_target(self, agent_name: str) -> None: + """No-op — target is implicit in ``delegation_start``.""" + + def add_delegation_sub_event( + self, event_type: str, tool_name: str, **kwargs + ) -> None: + """No-op for sub-events already handled by the executor's streaming loop.""" + + +class FrameworkAgentExecutor(AgentExecutor): + """Executes agent requests by bridging A2A → OpenAI Agents SDK. + + Each instance is bound to a specific agent via ``agent_name``. + There is no runtime routing — the agent identity is determined + by which A2A sub-app received the request. + + Supports both synchronous (``message/send``) and streaming + (``message/stream``) execution modes. + """ + + def __init__( + self, + agent_name: str, + agent_registry: AgentRegistry, + config: AppConfig, + mcp_server_url: str = "http://localhost:8001/mcp", + timeout: float = 300.0, + ): + self._agent_name = agent_name + self._registry = agent_registry + self._config = config + self._mcp_server_url = mcp_server_url + self._timeout = timeout # seconds; 0 or None = no timeout + # Track running tasks for cancellation + self._running_tasks: dict[str, asyncio.Task] = {} + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + async def _build_agent_context( + self, + context: RequestContext, + exit_stack: AsyncExitStack, + *, + call_graph=None, + ): + """Build the OpenAI Agents SDK Agent and extract metadata. + + Args: + call_graph: Optional call-graph adapter (e.g. ``A2ACallGraphAdapter``) + passed through to ``build_agent()`` so the delegate tool can + emit delegation events into the SSE stream. + + Returns ``(agent, user_input, depth)`` or raises on validation errors. + """ + agent_name = self._agent_name + agent_def = self._registry.get(agent_name) + if not agent_def: + available = self._registry.agent_names() + raise ValueError(f"Agent '{agent_name}' not found. Available: {available}") + + user_input_text = context.get_user_input() + if not user_input_text: + raise ValueError("No message content provided.") + + req_meta = context.metadata or {} + + # --- Multi-turn conversation history (framework:history) --- + # Clients can pass prior conversation turns via framework:history + # in either params.metadata or params.message.metadata. When + # present we build a list[EasyInputMessageParam] that the OpenAI + # Agents SDK Runner accepts as ``input``. + user_input: str | list[dict] = user_input_text + history = req_meta.get("framework:history") + if not history and context.message and context.message.metadata: + history = context.message.metadata.get("framework:history") + if history and isinstance(history, list): + input_messages: list[dict] = [] + for turn in history: + role = turn.get("role", "user") + # Normalize A2A role "agent" → OpenAI "assistant" + if role == "agent": + role = "assistant" + # Extract text from parts list or fall back to direct content + parts = turn.get("parts") + if isinstance(parts, list): + text = "\n".join( + p.get("text", "") for p in parts if isinstance(p, dict) + ) + else: + text = turn.get("content", "") + if text: + input_messages.append({"role": role, "content": text}) + # Append the current user message as the final turn + input_messages.append({"role": "user", "content": user_input_text}) + user_input = input_messages + logger.debug( + "Multi-turn input: %d history turns + current message", + len(input_messages) - 1, + ) + + # Read delegation depth from HTTP headers (via ServerCallContext.state) + # or from JSON-RPC metadata as fallback + depth = 0 + call_ctx = context.call_context + if call_ctx and call_ctx.state.get(_DEPTH_HEADER.lower()): + try: + depth = int(call_ctx.state[_DEPTH_HEADER.lower()]) + except (TypeError, ValueError): + pass + elif _DEPTH_HEADER in req_meta: + try: + depth = int(req_meta[_DEPTH_HEADER]) + except (TypeError, ValueError): + pass + + # Read client request id from HTTP headers (via ServerCallContext.state) + # This is the original JSON-RPC id from the client, propagated through + # all delegation hops so sub-agents and tools share the same session id. + client_request_id: str | None = None + if call_ctx and call_ctx.state.get(_CLIENT_ID_HEADER.lower()): + client_request_id = call_ctx.state[_CLIENT_ID_HEADER.lower()] + + from opensensa.orchestrator.agent_builder import build_agent + + # Infrastructure headers required by the MCP tool server. + # X-Conversation-Id is mandatory for tool logging; without it + # tool calls raise RuntimeError and the LLM reports a "technical issue". + mcp_headers: dict[str, str] = { + "X-Conversation-Id": context.task_id or "a2a-unknown", + "X-Agent-Id": agent_name, + "X-Agent-Depth": str(depth), + } + # Merge per-request context headers from client metadata. + request_headers = req_meta.get("context_headers", {}) + if isinstance(request_headers, dict): + mcp_headers.update(request_headers) + + remote_agents = [ + {"url": ra.url, "name": getattr(ra, "name", "")} + for ra in self._config.remote_agents + ] + + agent = await build_agent( + agent_def=agent_def, + config=self._config, + mcp_server_url=self._mcp_server_url, + exit_stack=exit_stack, + mcp_headers=mcp_headers if mcp_headers else None, + agent_registry=self._registry, + remote_agents=remote_agents or None, + call_graph=call_graph, + client_request_id=client_request_id, + context_headers=mcp_headers if mcp_headers else None, + current_depth=depth, + ) + + return agent, user_input, depth + + # ------------------------------------------------------------------ + # Non-streaming execution (message/send) + # ------------------------------------------------------------------ + + async def execute( + self, context: RequestContext, event_queue: EventQueue + ) -> None: + """Execute an agent request. + + Uses ``Runner.run_streamed()`` so that intermediate events (tool + calls, text deltas) are published to the EventQueue in real-time. + The A2A SDK's ``DefaultRequestHandler`` always calls this method + for both ``message/send`` and ``message/stream`` — the difference + is only in how events are delivered to the client. + """ + task_id = context.task_id or "unknown" + context_id = context.context_id or "unknown" + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task_id, + context_id=context_id, + ) + + trace = AgentTraceContext() + span = trace.start_span(self._agent_name, event_type="a2a_execute") + + try: + await updater.start_work() + + async with AsyncExitStack() as exit_stack: + call_graph_adapter = A2ACallGraphAdapter(updater, self._agent_name) + + agent, user_input, _depth = await self._build_agent_context( + context, exit_stack, call_graph=call_graph_adapter + ) + + logger.info(f"Executing agent '{self._agent_name}' for task {task_id}") + + from agents import Runner + from agents.stream_events import RunItemStreamEvent, RawResponsesStreamEvent + + streamed_result = Runner.run_streamed( + starting_agent=agent, + input=user_input, + max_turns=25, + ) + + self._running_tasks[task_id] = asyncio.current_task() + + accumulated_text = "" + chunk_count = 0 + _tracked_tools: list[dict] = [] + _pending_calls: dict[str, str] = {} # call_id → tool_name + _response_artifact_id = str(_uuid.uuid4()) + _first_artifact_chunk = True + + try: + async for event in streamed_result.stream_events(): + event_type = type(event).__name__ + logger.debug(f"[stream] event type={event_type}") + + # -- Token-level text deltas -- + if isinstance(event, RawResponsesStreamEvent): + raw_evt = event.data + raw_type = getattr(raw_evt, "type", "unknown") + if raw_type == "response.output_text.delta": + delta = getattr(raw_evt, "delta", "") + if delta: + accumulated_text += delta + chunk_count += 1 + + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=delta)] + ), + ) + + await updater.add_artifact( + parts=[TextPart(kind="text", text=delta)], + artifact_id=_response_artifact_id, + name=f"{self._agent_name}-response", + append=not _first_artifact_chunk, + last_chunk=False, + ) + _first_artifact_chunk = False + continue + + if isinstance(event, RunItemStreamEvent): + item = event.item + raw = getattr(item, "raw_item", None) + item_type = getattr(item, "type", "") + + # -- Tool call started -- + if item_type == "tool_call_item": + tool_name = getattr(raw, "name", None) or "unknown" + # McpCall uses 'id' instead of 'call_id' + call_id = getattr(raw, "call_id", None) or getattr(raw, "id", None) or "" + if call_id: + _pending_calls[call_id] = tool_name + + entry: dict = {"name": tool_name, "_cid": call_id} + evt: dict = {"__event__": "tool_start", "tool": tool_name} + + if tool_name == "delegate": + args_str = getattr(raw, "arguments", "") + if args_str: + try: + a_data = _json.loads(args_str) + aname = a_data.get("agent_name", "") + if aname: + entry["agent"] = aname + evt["agent"] = aname + except (ValueError, TypeError): + pass + + _tracked_tools.append(entry) + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message( + parts=[TextPart(kind="text", text="")], + metadata=_build_event_metadata(evt), + ), + ) + continue + + # -- Tool call finished -- + if item_type == "tool_call_output_item": + # raw_item is a TypedDict (FunctionCallOutput) or similar — extract call id + # McpCall uses 'id' instead of 'call_id', mirror SDK's _extract_call_id + if isinstance(raw, dict): + call_id = raw.get("call_id") or raw.get("id") or "" + else: + call_id = getattr(raw, "call_id", None) or getattr(raw, "id", None) or "" + tool_name = _pending_calls.pop(call_id, "unknown") + if isinstance(raw, dict): + output = str(raw.get("output", "") or getattr(item, "output", "") or "") + else: + output = str(getattr(raw, "output", "") or getattr(item, "output", "") or "") + + evt_data: dict = {"__event__": "tool_end", "tool": tool_name} + + if tool_name == "delegate" and output: + try: + d_data = _json.loads(output) + if d_data.get("agent"): + evt_data["agent"] = d_data["agent"] + if d_data.get("tools_used"): + evt_data["tools_used"] = d_data["tools_used"] + for t in _tracked_tools: + if t.get("_cid") == call_id: + if d_data.get("agent"): + t["agent"] = d_data["agent"] + if d_data.get("tools_used"): + t["tools_used"] = d_data["tools_used"] + break + except (ValueError, TypeError): + pass + + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message( + parts=[TextPart(kind="text", text="")], + metadata=_build_event_metadata(evt_data), + ), + ) + continue + + # -- Text content (fallback if no raw deltas) -- + if raw is not None and chunk_count == 0: + content_parts = getattr(raw, "content", []) + for part in content_parts: + text = getattr(part, "text", None) + if text: + chunk_text = text[len(accumulated_text):] if text.startswith(accumulated_text) else text + if chunk_text: + accumulated_text = text + chunk_count += 1 + + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=chunk_text)] + ), + ) + + await updater.add_artifact( + parts=[TextPart(kind="text", text=chunk_text)], + artifact_id=_response_artifact_id, + name=f"{self._agent_name}-response", + append=not _first_artifact_chunk, + last_chunk=False, + ) + _first_artifact_chunk = False + + finally: + self._running_tasks.pop(task_id, None) + + # Extract final text + final_text = "" + if streamed_result.is_complete: + fo = getattr(streamed_result, "final_output", None) + if fo: + final_text = str(fo) if not isinstance(fo, str) else fo + + if not final_text: + final_text = accumulated_text or "Agent completed with no output." + + # Publish execution-trace artifact with tool call info + for t in _tracked_tools: + t.pop("_cid", None) + if _tracked_tools: + await updater.add_artifact( + parts=[DataPart( + kind="data", + data={"tools_used": _tracked_tools}, + )], + name=f"{self._agent_name}-execution-trace", + ) + + # Close the response artifact + await updater.add_artifact( + parts=[TextPart(kind="text", text="")], + artifact_id=_response_artifact_id, + name=f"{self._agent_name}-response", + append=not _first_artifact_chunk, + last_chunk=True, + ) + + self._record_usage(span, streamed_result) + + await updater.complete( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=final_text)] + ) + ) + + span.complete( + "completed", + output_length=len(final_text), + chunks=chunk_count, + ) + logger.info( + f"Agent '{self._agent_name}' completed task {task_id} " + f"({chunk_count} chunks, output length={len(final_text)})" + ) + + except ValueError as ve: + span.complete("error", error=str(ve)) + await updater.failed( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=str(ve))] + ) + ) + except asyncio.CancelledError: + span.complete("cancelled") + logger.info(f"Agent execution cancelled for task {task_id}") + await updater.cancel( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text="Task was cancelled.")] + ) + ) + except asyncio.TimeoutError: + span.complete("timeout") + logger.warning(f"Agent execution timed out for task {task_id} ({self._timeout}s)") + self._running_tasks.pop(task_id, None) + await updater.failed( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=f"Agent execution timed out after {self._timeout}s.")] + ) + ) + except Exception as e: + span.complete("error", error=str(e)) + logger.error(f"Agent execution failed for task {task_id}: {e}", exc_info=True) + await updater.failed( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text=f"Agent execution error: {e}")] + ) + ) + + # ------------------------------------------------------------------ + # Cancel + # ------------------------------------------------------------------ + + async def cancel( + self, context: RequestContext, event_queue: EventQueue + ) -> None: + """Cancel a running agent execution.""" + task_id = context.task_id or "unknown" + context_id = context.context_id or "unknown" + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task_id, + context_id=context_id, + ) + + running = self._running_tasks.get(task_id) + if running and not running.done(): + running.cancel() + logger.info(f"Cancelled running task: {task_id}") + + await updater.cancel( + message=updater.new_agent_message( + parts=[TextPart(kind="text", text="Task cancelled by request.")] + ) + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + + @staticmethod + def _record_usage(span, result) -> None: + """Record token usage from the result into a trace span.""" + raw_responses = getattr(result, "raw_responses", None) + if not raw_responses: + return + total_in = total_out = 0 + for resp in raw_responses: + u = getattr(resp, "usage", None) + if u: + total_in += getattr(u, "input_tokens", 0) + total_out += getattr(u, "output_tokens", 0) + if total_in or total_out: + span.usage = { + "input_tokens": total_in, + "output_tokens": total_out, + "total_tokens": total_in + total_out, + } diff --git a/src/opensensa/a2a/task_store.py b/src/opensensa/a2a/task_store.py new file mode 100644 index 0000000..d845a25 --- /dev/null +++ b/src/opensensa/a2a/task_store.py @@ -0,0 +1,26 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""A2A Task Store — re-exports InMemoryTaskStore from a2a-sdk. + +For v1, we use the SDK's in-memory implementation. Swap to a DB-backed +store (e.g. a2a-sdk's DatabaseTaskStore with SQLAlchemy) when persistence +is needed. +""" + +from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore + +__all__ = ["InMemoryTaskStore"] diff --git a/src/opensensa/agents/__init__.py b/src/opensensa/agents/__init__.py new file mode 100644 index 0000000..9cfc238 --- /dev/null +++ b/src/opensensa/agents/__init__.py @@ -0,0 +1,30 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Bundled agent definitions — markdown files with YAML frontmatter. + +These are the default agents copied into user projects on `opensensa init`. +The actual agent files live alongside this __init__.py as .md files. +""" + +from pathlib import Path + +AGENTS_DIR = Path(__file__).parent + + +def list_bundled_agents() -> list[Path]: + """Return paths to all bundled agent .md files.""" + return sorted(AGENTS_DIR.glob("*.md")) diff --git a/src/opensensa/agents/agent-manager.md b/src/opensensa/agents/agent-manager.md new file mode 100644 index 0000000..8e087d6 --- /dev/null +++ b/src/opensensa/agents/agent-manager.md @@ -0,0 +1,60 @@ +--- +name: agent-manager +description: Creates, edits, and deletes agents from natural language descriptions +model: ${default} +tools: + - list_tools + - create_agent + - edit_agent + - delete_agent + - discover_agents +skills: + - id: agent-management + name: Agent Management + description: Create, edit, and delete AI agents from natural language descriptions + tags: [meta, agent-management, onboarding] + examples: + - "Create an agent that can analyze CSV files" + - "I need a data visualization agent" + - "Update the research-assistant agent to use gpt-4.1" + - "Change the system prompt for my data-analyst agent" + - "Remove the old test-agent" + - "Add the csv_formatter tool to my data-analyst agent" +input_modes: ["text/plain"] +output_modes: ["text/plain"] +--- + +# Agent Manager + +You help users create, edit, and delete AI agents. + +## Creating a new agent + +1. Ask what the agent should do +2. Call list_tools to see what tools are available +3. Propose a complete agent spec: name, description, model, tools, and system prompt +4. Present the spec to the user for review +5. Iterate on feedback until the user approves +6. Call create_agent to register it + +## Editing an existing agent + +1. Call discover_agents to list available agents if the user hasn't specified one +2. Confirm which agent and which fields (description, model, tools, system prompt) to change +3. Show the user what will change before applying +4. Call edit_agent with only the fields that need updating + +## Deleting an agent + +1. Confirm the agent name with the user +2. Call delete_agent with confirm=False first to show what will be removed +3. Only call delete_agent with confirm=True after the user explicitly approves + +## Guidelines +- Suggest descriptive kebab-case names (e.g., "clinical-trial-analyst", "data-visualizer") +- Write clear, specific system prompts — not generic ones +- Only suggest tools that are actually available (always call list_tools first) +- Always show the user the full spec before creating or editing +- If the user wants multi-agent collaboration, suggest adding sub_agents to the agent's frontmatter +- When editing, only change the fields the user asked about — leave everything else as-is +- Never delete an agent without explicit user confirmation diff --git a/src/opensensa/cli.py b/src/opensensa/cli.py new file mode 100644 index 0000000..e802d28 --- /dev/null +++ b/src/opensensa/cli.py @@ -0,0 +1,608 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""OpenSensa CLI — Click-based command-line interface. + +Commands: + opensensa — interactive chat (default) + opensensa chat [agent] — chat with an agent interactively + opensensa init [dir] — scaffold a new project + opensensa serve — start MCP + A2A servers (headless) + opensensa add-tool — generate tool skeleton + opensensa add-agent — generate agent skeleton + opensensa list-tools — list MCP tools + opensensa list-agents — list agents + opensensa test — smoke test +""" + +import asyncio +import shutil +import sys +from pathlib import Path +from typing import Optional + +import click + +# Package root for bundled files +_PACKAGE_ROOT = Path(__file__).parent + + +# --------------------------------------------------------------------------- +# CLI group +# --------------------------------------------------------------------------- + +@click.group(invoke_without_command=True) +@click.version_option(package_name="opensensa") +@click.pass_context +def cli(ctx): + """OpenSensa — AI agent framework with A2A + MCP.""" + # Load .env FIRST, before any command touches config or env vars. + # override=True so the .env always wins over stale shell exports. + # usecwd=True is CRITICAL — without it find_dotenv() walks up from + # cli.py's install location, not the user's project directory. + from dotenv import load_dotenv, find_dotenv + load_dotenv(find_dotenv(usecwd=True), override=True) + + if ctx.invoked_subcommand is None: + ctx.invoke(chat) + + +# --------------------------------------------------------------------------- +# opensensa init +# --------------------------------------------------------------------------- + +@cli.command() +@click.argument("directory", default=".") +def init(directory: str): + """Scaffold a new OpenSensa project. + + Creates opensensa.yaml, agents/ with Agent Creator, and tools/ with sample tools. + """ + project_dir = Path(directory).resolve() + + click.echo(f"Initializing OpenSensa project in {project_dir}") + + project_dir.mkdir(parents=True, exist_ok=True) + + # --- opensensa.yaml --- + config_dest = project_dir / "opensensa.yaml" + if config_dest.exists(): + click.echo(f" [skip] opensensa.yaml already exists") + else: + config_src = _PACKAGE_ROOT.parent.parent.parent / "opensensa.example.yaml" + if not config_src.exists(): + # Fallback: generate inline + config_dest.write_text(_default_config(), encoding="utf-8") + else: + shutil.copy2(config_src, config_dest) + click.echo(f" [created] opensensa.yaml") + + # --- agents/ directory + bundled agents --- + agents_dir = project_dir / "agents" + agents_dir.mkdir(exist_ok=True) + + bundled_agents_dir = _PACKAGE_ROOT / "agents" + if bundled_agents_dir.exists(): + for md_file in bundled_agents_dir.glob("*.md"): + dest = agents_dir / md_file.name + if dest.exists(): + click.echo(f" [skip] agents/{md_file.name} already exists") + else: + shutil.copy2(md_file, dest) + click.echo(f" [created] agents/{md_file.name}") + else: + click.echo(f" [warn] No bundled agents found at {bundled_agents_dir}") + + # --- tools/ directory + bundled example tools --- + tools_dir = project_dir / "tools" + tools_dir.mkdir(exist_ok=True) + + bundled_tools_dir = _PACKAGE_ROOT / "tools" + if bundled_tools_dir.exists(): + for py_file in bundled_tools_dir.glob("*.py"): + if py_file.name.startswith("_"): + continue + dest = tools_dir / py_file.name + if dest.exists(): + click.echo(f" [skip] tools/{py_file.name} already exists") + else: + shutil.copy2(py_file, dest) + click.echo(f" [created] tools/{py_file.name}") + else: + click.echo(f" [warn] No bundled tools found at {bundled_tools_dir}") + + # --- .env skeleton --- + env_file = project_dir / ".env" + if not env_file.exists(): + env_file.write_text( + "# OpenSensa environment variables\n" + "# OPENAI_API_KEY=sk-...\n", + encoding="utf-8", + ) + click.echo(f" [created] .env") + + click.echo() + click.echo("Done! Next steps:") + click.echo(f" cd {project_dir}") + click.echo(" # Edit opensensa.yaml with your model endpoint") + click.echo(" # Set OPENAI_API_KEY in .env or environment") + click.echo(" opensensa serve") + + +# --------------------------------------------------------------------------- +# opensensa serve +# --------------------------------------------------------------------------- + +@cli.command() +@click.option("--host", default=None, help="Override bind host.") +@click.option("--mcp-port", default=None, type=int, help="Override MCP server port.") +@click.option("--orchestrator-port", default=None, type=int, help="Override orchestrator port.") +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +@click.option("--reload", is_flag=True, help="Auto-reload on file changes.") +@click.option("--web/--no-web", default=True, help="Enable/disable the web UI (default: enabled).") +def serve(host, mcp_port, orchestrator_port, config_path, reload, web): + """Start the MCP tool server and A2A agent server (headless).""" + from opensensa.config import load_config + from opensensa.utils.logging import setup_logging + + config = load_config(config_path) + setup_logging(config.logging.level, config.logging.file) + + # Apply overrides + if host: + config.server.host = host + if mcp_port: + config.server.mcp_port = mcp_port + if orchestrator_port: + config.server.orchestrator_port = orchestrator_port + + _start_both(config, reload=reload, enable_web=web) + + +def _start_mcp_server(config, reload: bool = False): + """Start only the MCP tool server.""" + import uvicorn + + from opensensa.mcp_server.server import create_mcp_server + from opensensa.mcp_server.tool_loader import discover_and_load_tools + + click.echo(f"Starting MCP server on {config.server.host}:{config.server.mcp_port}") + + mcp = create_mcp_server( + host=config.server.host, + port=config.server.mcp_port, + ) + + # Register framework tools + _register_framework_tools(mcp, config) + + # Load user tools + tools_dir = Path(config.tools.directory).resolve() + discover_and_load_tools(tools_dir, mcp) + + # Run via FastMCP's built-in server + mcp.run(transport="streamable-http") + + +def _start_orchestrator(config, reload: bool = False, enable_web: bool = True): + """Start the A2A orchestrator with per-agent sub-apps.""" + import uvicorn + + from opensensa.orchestrator.agent_registry import AgentRegistry + from opensensa.orchestrator.server import create_orchestrator_app + + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + registry.scan() + + mcp_server_url = f"http://localhost:{config.server.mcp_port}/mcp" + + click.echo(f"Starting A2A server on {config.server.host}:{config.server.orchestrator_port}") + click.echo(f"Agents: {registry.agent_names()}") + for name in registry.agent_names(): + click.echo(f" /agents/{name}/") + if enable_web: + advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host + click.echo(f" Web UI: http://{advertise_host}:{config.server.orchestrator_port}/web") + + app = create_orchestrator_app(config, registry, mcp_server_url=mcp_server_url, enable_web=enable_web) + + uvicorn.run( + app, + host=config.server.host, + port=config.server.orchestrator_port, + log_level=config.logging.level, + reload=reload, + ) + + +def _start_both(config, reload: bool = False, enable_web: bool = True): + """Start both MCP and orchestrator servers in separate processes.""" + import multiprocessing + + from opensensa.orchestrator.agent_registry import AgentRegistry + + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + agent_names = registry.agent_names() + + click.echo("Starting OpenSensa servers:") + click.echo(f" MCP tool server: {config.server.host}:{config.server.mcp_port}") + click.echo(f" A2A agent server: {config.server.host}:{config.server.orchestrator_port}") + for name in agent_names: + click.echo(f" /agents/{name}/") + if enable_web: + advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host + click.echo(f" Web UI: http://{advertise_host}:{config.server.orchestrator_port}/web") + click.echo() + + mcp_proc = multiprocessing.Process( + target=_start_mcp_server, + args=(config,), + kwargs={"reload": reload}, + daemon=True, + ) + orch_proc = multiprocessing.Process( + target=_start_orchestrator, + args=(config,), + kwargs={"reload": reload, "enable_web": enable_web}, + daemon=True, + ) + + mcp_proc.start() + orch_proc.start() + + try: + click.echo("Press Ctrl+C to stop both servers.") + mcp_proc.join() + orch_proc.join() + except KeyboardInterrupt: + click.echo("\nShutting down...") + mcp_proc.terminate() + orch_proc.terminate() + mcp_proc.join(timeout=5) + orch_proc.join(timeout=5) + click.echo("Stopped.") + + +# --------------------------------------------------------------------------- +# opensensa add-tool +# --------------------------------------------------------------------------- + +@cli.command("add-tool") +@click.argument("name") +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def add_tool(name: str, config_path: Optional[str]): + """Generate a tool skeleton file.""" + from opensensa.config import load_config + + config = load_config(config_path) + tools_dir = Path(config.tools.directory).resolve() + tools_dir.mkdir(parents=True, exist_ok=True) + + # Sanitize name + safe_name = name.replace("-", "_").replace(" ", "_").lower() + file_path = tools_dir / f"{safe_name}.py" + + if file_path.exists(): + click.echo(f"Error: {file_path} already exists", err=True) + sys.exit(1) + + file_path.write_text( + f'"""Tool: {name}\n' + f'\n' + f'Drop this file into your tools/ directory. The @mcp.tool() decorator\n' + f'registers it automatically when the MCP server starts.\n' + f'"""\n' + f'\n' + f'from fastmcp import FastMCP\n' + f'\n' + f'# This will be set by the tool loader — do not call get_mcp() at import time.\n' + f'# Just define your register() function and the framework will call it.\n' + f'\n' + f'\n' + f'def register(mcp: FastMCP):\n' + f' """Register the {safe_name} tool. Called automatically by the tool loader."""\n' + f'\n' + f' @mcp.tool(\n' + f' title="{name.replace("_", " ").title()}",\n' + f' description="TODO: describe what this tool does",\n' + f' )\n' + f' async def {safe_name}(query: str) -> str:\n' + f' """TODO: implement this tool."""\n' + f' return f"Result for: {{query}}"\n', + encoding="utf-8", + ) + + click.echo(f"Created tool skeleton: {file_path}") + + +# --------------------------------------------------------------------------- +# opensensa add-agent +# --------------------------------------------------------------------------- + +@cli.command("add-agent") +@click.argument("name") +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def add_agent(name: str, config_path: Optional[str]): + """Generate an agent skeleton .md file.""" + from opensensa.config import load_config + + config = load_config(config_path) + agents_dir = Path(config.agents.directory).resolve() + agents_dir.mkdir(parents=True, exist_ok=True) + + # Sanitize name to kebab-case + safe_name = name.replace("_", "-").replace(" ", "-").lower() + file_path = agents_dir / f"{safe_name}.md" + + if file_path.exists(): + click.echo(f"Error: {file_path} already exists", err=True) + sys.exit(1) + + file_path.write_text( + f"---\n" + f"name: {safe_name}\n" + f"description: TODO — describe what this agent does\n" + f"model: ${{default}}\n" + f"tools: []\n" + f"sub_agents: []\n" + f"skills:\n" + f" - id: {safe_name}-skill\n" + f" name: {name.replace('-', ' ').replace('_', ' ').title()}\n" + f" description: TODO — describe this skill\n" + f" tags: []\n" + f"input_modes: [\"text/plain\"]\n" + f"output_modes: [\"text/plain\"]\n" + f"---\n" + f"\n" + f"# {name.replace('-', ' ').replace('_', ' ').title()}\n" + f"\n" + f"You are a helpful assistant that specializes in...\n" + f"\n" + f"## Guidelines\n" + f"- TODO: add guidelines\n", + encoding="utf-8", + ) + + click.echo(f"Created agent skeleton: {file_path}") + + +# --------------------------------------------------------------------------- +# opensensa list-tools +# --------------------------------------------------------------------------- + +@cli.command("list-tools") +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def list_tools(config_path: Optional[str]): + """List available MCP tools.""" + from opensensa.config import load_config + from opensensa.mcp_server.server import create_mcp_server + from opensensa.mcp_server.tool_loader import discover_and_load_tools + + config = load_config(config_path) + + mcp = create_mcp_server(enable_logging=False) + _register_framework_tools(mcp, config) + + tools_dir = Path(config.tools.directory).resolve() + discover_and_load_tools(tools_dir, mcp) + + if hasattr(mcp, "registered_tools"): + tools = mcp.registered_tools + if not tools: + click.echo("No tools registered.") + return + click.echo(f"{'Tool':<30} {'Title':<30} {'Tags'}") + click.echo("-" * 90) + for name, meta in sorted(tools.items()): + title = meta.get("title", "") or "" + tags = ", ".join(meta.get("tags", [])) + click.echo(f"{meta.get('function', name):<30} {title:<30} {tags}") + else: + click.echo("Tool introspection not available (LoggedMCP not enabled)") + + +# --------------------------------------------------------------------------- +# opensensa list-agents +# --------------------------------------------------------------------------- + +@cli.command("list-agents") +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def list_agents(config_path: Optional[str]): + """List local and remote agents.""" + from opensensa.config import load_config + from opensensa.orchestrator.agent_registry import AgentRegistry + + config = load_config(config_path) + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + + agents = registry.list_agents() + if not agents: + click.echo("No agents found.") + click.echo(f"(Looked in: {agents_dir})") + return + + click.echo(f"{'Agent':<25} {'Model':<20} {'Tools':<30} {'Description'}") + click.echo("-" * 110) + for a in agents: + tools_str = ", ".join(a.tools[:3]) + if len(a.tools) > 3: + tools_str += f" (+{len(a.tools) - 3})" + click.echo(f"{a.name:<25} {a.model:<20} {tools_str:<30} {a.description[:40]}") + + # Remote agents + if config.remote_agents: + click.echo() + click.echo("Remote agents:") + for ra in config.remote_agents: + click.echo(f" {ra.url}") + + +# --------------------------------------------------------------------------- +# opensensa test +# --------------------------------------------------------------------------- + +@cli.command() +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def test(config_path: Optional[str]): + """Run a quick smoke test — list tools and agents.""" + from opensensa.config import load_config + from opensensa.orchestrator.agent_registry import AgentRegistry + + config = load_config(config_path) + + # Check config + click.echo("Configuration:") + click.echo(f" Models: {list(config.models.registry.keys())}") + click.echo(f" Default model: {config.models.default}") + click.echo(f" MCP port: {config.server.mcp_port}") + click.echo(f" Orchestrator port: {config.server.orchestrator_port}") + click.echo() + + # Check agents + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + agents = registry.list_agents() + click.echo(f"Agents ({len(agents)}):") + for a in agents: + click.echo(f" {a.name}: {a.description}") + click.echo() + + # Check tools directory + tools_dir = Path(config.tools.directory).resolve() + if tools_dir.exists(): + tool_files = list(tools_dir.glob("*.py")) + click.echo(f"Tool files ({len(tool_files)}):") + for f in tool_files: + if not f.name.startswith("_"): + click.echo(f" {f.name}") + else: + click.echo(f"Tools directory not found: {tools_dir}") + + click.echo() + click.echo("Smoke test passed.") + + +# --------------------------------------------------------------------------- +# opensensa chat (interactive mode — the DEFAULT command) +# --------------------------------------------------------------------------- + +@cli.command() +@click.argument("agent_name", default=None, required=False) +@click.option("--config", "config_path", default=None, help="Path to opensensa.yaml.") +def chat(agent_name: Optional[str], config_path: Optional[str]): + """Start an interactive chat session with an agent. + + If AGENT_NAME is omitted the CLI will auto-select (when only one agent + exists) or show a picker. + """ + from opensensa.config import load_config, resolve_model + from opensensa.orchestrator.agent_registry import AgentRegistry + from opensensa.utils.logging import setup_logging + + config = load_config(config_path) + setup_logging(config.logging.level, config.logging.file, console_output=False) + + # Validate that every model has a usable API key before starting + for model_name, model_cfg in config.models.registry.items(): + key = model_cfg.api_key or "" + if key.startswith("${") or not key.strip(): + click.echo() + click.echo(f" \033[1;31m✖ Error:\033[0m API key for model '{model_name}' is not set.") + click.echo(f" The config has: api_key: {key or '(empty)'}") + click.echo() + click.echo(" Fix: set the key in your .env file or shell environment:") + click.echo(f" echo 'OPENAI_API_KEY=sk-...' >> .env") + click.echo() + sys.exit(1) + + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + + from opensensa.interactive.chat import start_chat + + asyncio.run(start_chat(config, registry, agent_name)) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _register_framework_tools(mcp, config): + """Register the 4 framework-provided MCP tools.""" + from opensensa.orchestrator.agent_registry import AgentRegistry + + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + + remote_agents = [{"url": ra.url} for ra in config.remote_agents] + + # Build local base URL so discover_agents can return reachable URLs + advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host + local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" + + # Import and register each framework tool + from opensensa.framework_tools import discover_agents, send_to_agent, create_agent, edit_agent, delete_agent, list_tools + + discover_agents.register( + mcp, + agent_registry=registry, + remote_agents=remote_agents, + local_base_url=local_base_url, + ) + send_to_agent.register(mcp) + # NOTE: delegate is NOT registered on MCP — it is a native FunctionTool + # wired directly into the Agent by agent_builder.py (A2A, not MCP). + create_agent.register(mcp, agents_directory=config.agents.directory) + edit_agent.register(mcp, agents_directory=config.agents.directory) + delete_agent.register(mcp, agents_directory=config.agents.directory) + list_tools.register(mcp) + + +def _default_config() -> str: + """Generate default opensensa.yaml content inline.""" + return """\ +# OpenSensa Configuration + +models: + default: my-model + registry: + my-model: + base_url: https://api.openai.com/v1 + api_key: ${OPENAI_API_KEY} + model_name: gpt-4.1-mini + +server: + host: 0.0.0.0 + orchestrator_port: 8000 + mcp_port: 8001 + +agents: + directory: ./agents/ + +tools: + directory: ./tools/ + builtin: true + +logging: + level: info +""" + + +if __name__ == "__main__": + cli() diff --git a/src/opensensa/config.py b/src/opensensa/config.py new file mode 100644 index 0000000..c9697ad --- /dev/null +++ b/src/opensensa/config.py @@ -0,0 +1,169 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""YAML config loader with Pydantic validation and env var interpolation.""" + +import os +import re +from pathlib import Path +from typing import Any, Optional + +import yaml +from pydantic import BaseModel, Field, field_validator + + +# --------------------------------------------------------------------------- +# Pydantic models for opensensa.yaml +# --------------------------------------------------------------------------- + +class ModelConfig(BaseModel): + """A single model entry in the registry.""" + base_url: str + api_key: str = "none" + model_name: str + +class ModelsConfig(BaseModel): + """Top-level models section.""" + default: str = "default" + registry: dict[str, ModelConfig] = Field(default_factory=dict) + +class ServerConfig(BaseModel): + """Server host/port settings.""" + host: str = "0.0.0.0" + orchestrator_port: int = 8000 + mcp_port: int = 8001 + +class AgentsConfig(BaseModel): + """Agents directory settings.""" + directory: str = "./agents/" + +class ToolsConfig(BaseModel): + """Tools directory settings.""" + directory: str = "./tools/" + builtin: bool = True + +class RemoteAgentConfig(BaseModel): + """A single remote A2A agent.""" + url: str + +class LoggingConfig(BaseModel): + """Logging settings.""" + level: str = "info" + file: Optional[str] = "./logs/opensensa.jsonl" + + @field_validator("level") + @classmethod + def validate_level(cls, v: str) -> str: + allowed = {"debug", "info", "warning", "error", "critical"} + if v.lower() not in allowed: + raise ValueError(f"logging.level must be one of {allowed}, got '{v}'") + return v.lower() + +class AppConfig(BaseModel): + """Root configuration model for the framework YAML config file.""" + models: ModelsConfig = Field(default_factory=ModelsConfig) + server: ServerConfig = Field(default_factory=ServerConfig) + agents: AgentsConfig = Field(default_factory=AgentsConfig) + tools: ToolsConfig = Field(default_factory=ToolsConfig) + remote_agents: list[RemoteAgentConfig] = Field(default_factory=list) + logging: LoggingConfig = Field(default_factory=LoggingConfig) + + +# --------------------------------------------------------------------------- +# Environment variable interpolation +# --------------------------------------------------------------------------- + +_ENV_VAR_RE = re.compile(r"\$\{(\w+)(?::([^}]*))?\}") + + +def _interpolate_env(value: Any) -> Any: + """Recursively interpolate ${VAR} and ${VAR:default} in strings.""" + if isinstance(value, str): + def _replace(match: re.Match) -> str: + var_name = match.group(1) + default = match.group(2) + env_val = os.environ.get(var_name) + if env_val is not None: + return env_val + if default is not None: + return default + return match.group(0) # Leave as-is if not found and no default + return _ENV_VAR_RE.sub(_replace, value) + elif isinstance(value, dict): + return {k: _interpolate_env(v) for k, v in value.items()} + elif isinstance(value, list): + return [_interpolate_env(item) for item in value] + return value + + +# --------------------------------------------------------------------------- +# Config loading +# --------------------------------------------------------------------------- + +def load_config(config_path: Optional[str | Path] = None, project_dir: Optional[str | Path] = None) -> AppConfig: + """Load and validate the framework YAML configuration. + + Args: + config_path: Explicit path to a YAML config file. If None, searches + for opensensa.yaml in project_dir, then cwd. + project_dir: Project root to resolve relative paths against. Defaults to cwd. + + Returns: + Validated AppConfig instance. + """ + project_dir = Path(project_dir) if project_dir else Path.cwd() + + # Locate config file + if config_path: + config_file = Path(config_path) + else: + config_file = project_dir / "opensensa.yaml" + if not config_file.exists(): + config_file = project_dir / "opensensa.yml" + + # Load YAML + if config_file.exists(): + with open(config_file) as f: + raw = yaml.safe_load(f) or {} + else: + raw = {} + + # Interpolate env vars + raw = _interpolate_env(raw) + + # Validate with Pydantic + config = AppConfig.model_validate(raw) + + return config + + +def resolve_model(config: AppConfig, model_ref: str) -> ModelConfig: + """Resolve a model reference to its ModelConfig. + + Handles: + - "${default}" or "default" → looks up config.models.default + - Exact name in registry + """ + if model_ref in ("${default}", "default"): + model_ref = config.models.default + + if model_ref not in config.models.registry: + available = list(config.models.registry.keys()) + raise ValueError( + f"Model '{model_ref}' not found in registry. Available: {available}" + ) + + return config.models.registry[model_ref] diff --git a/src/opensensa/framework_tools/__init__.py b/src/opensensa/framework_tools/__init__.py new file mode 100644 index 0000000..8c8ed60 --- /dev/null +++ b/src/opensensa/framework_tools/__init__.py @@ -0,0 +1,24 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework-provided tools. + +MCP tools (registered on the MCP server): discover_agents, send_to_agent, +create_agent, edit_agent, delete_agent, list_tools. + +Native tools (attached directly to Agent objects): delegate — uses A2A +for agent-to-agent communication, bypassing MCP entirely. +""" diff --git a/src/opensensa/framework_tools/create_agent.py b/src/opensensa/framework_tools/create_agent.py new file mode 100644 index 0000000..bbb42aa --- /dev/null +++ b/src/opensensa/framework_tools/create_agent.py @@ -0,0 +1,108 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: create_agent — writes a new agent .md file to the agents directory.""" + +import logging +import re +from pathlib import Path +from typing import Any, Optional + +import yaml + +logger = logging.getLogger("opensensa.framework_tools") + + +def register(mcp, *, agents_directory: str | Path | None = None): + """Register the create_agent tool. + + Args: + mcp: The MCP server instance. + agents_directory: Path where agent .md files are written. + """ + + @mcp.tool( + title="Create Agent", + description=( + "Create a new AI agent by writing a .md definition file. Provide the agent's " + "name, description, model, tools, and system prompt. The agent becomes available " + "immediately without restart." + ), + tags=["framework", "meta", "agent-creation"], + ) + def create_agent( + name: str, + description: str, + system_prompt: str, + model: str = "${default}", + tools: Optional[list[str]] = None, + sub_agents: Optional[list[str]] = None, + ) -> dict[str, Any]: + """Write a new agent .md file to the agents directory. + + Args: + name: Agent name in kebab-case (e.g. "data-analyst"). + description: One-line description of what the agent does. + system_prompt: The full system prompt (markdown body of the file). + model: Model reference (default: "${default}"). + tools: List of tool names the agent should have access to. + sub_agents: List of agent names this agent can delegate to via the delegate tool. + + Returns: + Confirmation dict with file path and agent name. + """ + if not agents_directory: + return {"error": "Agents directory not configured"} + + agents_dir = Path(agents_directory).resolve() + agents_dir.mkdir(parents=True, exist_ok=True) + + # Validate name — must be kebab-case (a-z, 0-9, hyphens) + if len(name) <= 2 or not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", name): + # Be lenient — sanitize instead of rejecting + safe_name = re.sub(r"[^a-z0-9-]", "-", name.lower()).strip("-") + if not safe_name or len(safe_name) < 2: + return {"error": f"Invalid agent name: '{name}'"} + name = safe_name + + file_path = agents_dir / f"{name}.md" + if file_path.exists(): + return {"error": f"Agent '{name}' already exists at {file_path}"} + + # Build frontmatter + frontmatter: dict[str, Any] = { + "name": name, + "description": description, + "model": model, + } + if tools: + frontmatter["tools"] = tools + if sub_agents: + frontmatter["sub_agents"] = sub_agents + + # Write the file + yaml_block = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False).strip() + content = f"---\n{yaml_block}\n---\n\n{system_prompt.strip()}\n" + + file_path.write_text(content, encoding="utf-8") + logger.info(f"Created agent: {name} at {file_path}") + + return { + "status": "created", + "name": name, + "file_path": str(file_path), + "message": f"Agent '{name}' is now available.", + } diff --git a/src/opensensa/framework_tools/delegate.py b/src/opensensa/framework_tools/delegate.py new file mode 100644 index 0000000..77b2e7f --- /dev/null +++ b/src/opensensa/framework_tools/delegate.py @@ -0,0 +1,521 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: delegate — native function tool for agent-to-agent delegation. + +Delegation is an A2A (agent-to-agent) operation, not a tool operation. +This module builds a native ``FunctionTool`` (from the OpenAI Agents SDK) +that calls sub-agents directly over A2A — bypassing MCP entirely. + +This avoids MCP's short tool-call timeout (5 s default) which is inappropriate +for delegation calls that involve full LLM round-trips on the target agent. + +Usage in ``agent_builder.py``:: + + from opensensa.framework_tools.delegate import build_delegate_tool + tool = build_delegate_tool(agent_def, registry, ...) + Agent(..., tools=[tool, ...]) +""" + +import json +import logging +import uuid +from typing import Any + +import httpx + +logger = logging.getLogger("opensensa.framework_tools") + +# Maximum delegation depth to prevent infinite loops +MAX_DELEGATION_DEPTH = 5 + +# Header used to propagate current delegation depth +_DEPTH_HEADER = "X-A2A-Depth" + +# Timeout for the A2A HTTP call (seconds) — generous because the sub-agent +# runs a full LLM turn which can take 30-60 s. +_A2A_TIMEOUT = 120.0 + + +# --------------------------------------------------------------------------- +# Async-aware delegation event helpers +# --------------------------------------------------------------------------- + +async def _notify_delegation_start( + call_graph, from_agent: str, to_agent: str, *, message: str = "" +) -> None: + """Emit a delegation_start event, using async path when available.""" + if call_graph is None: + return + if hasattr(call_graph, "emit_delegation_start"): + await call_graph.emit_delegation_start( + from_agent, to_agent, message=message + ) + else: + call_graph.start_delegation(from_agent, to_agent, message=message) + + +async def _notify_delegation_end( + call_graph, to_agent: str, *, response: str = "" +) -> None: + """Emit a delegation_end event, using async path when available.""" + if call_graph is None: + return + if hasattr(call_graph, "emit_delegation_end"): + await call_graph.emit_delegation_end(to_agent, response=response) + else: + call_graph.end_delegation(to_agent, response=response) + + +def _extract_response_text(result: dict) -> str: + """Extract a human-readable response string from an A2A task result. + + When the sub-agent streams its response, each token/chunk arrives as a + separate ``artifact-update`` SSE event (with ``append: true``). We + concatenate all non-trace text parts to reconstruct the full response. + """ + parts: list[str] = [] + for artifact in result.get("artifacts", []): + name = artifact.get("name", "") + if name.endswith("-execution-trace"): + continue + for part in artifact.get("parts", []): + text = part.get("text", "") + if text: + parts.append(text) + return "".join(parts) + + +def _resolve_agent_url( + agent_name: str, + *, + agent_registry=None, + local_base_url: str = "http://localhost:8000", + remote_agents: list[dict] | None = None, +) -> str | None: + """Resolve an agent name to its A2A endpoint URL. + + Checks local agents first, then remote agents. + """ + if agent_registry: + defn = agent_registry.get(agent_name) + if defn is not None: + return f"{local_base_url.rstrip('/')}/agents/{agent_name}" + + for entry in (remote_agents or []): + url = entry.get("url", "").rstrip("/") + name = entry.get("name", "") + if name == agent_name and url: + return url + + return None + + +async def _try_stream_delegation( + a2a_endpoint: str, + params: dict, + headers: dict, + *, + call_graph=None, + client_request_id: str | None = None, +) -> tuple[list[dict], dict]: + """Attempt a streaming A2A delegation (``message/stream``). + + Uses ``httpx_sse`` (the same SSE parser the a2a-sdk uses internally) + for reliable server-sent event parsing. Falls through on any + failure — caller should fall back to synchronous ``message/send``. + + Returns ``(tools_used, task_result)`` where *tools_used* is the + sub-agent's execution trace and *task_result* is a dict with at + least an ``artifacts`` key. + """ + from httpx_sse import aconnect_sse + + payload = { + "jsonrpc": "2.0", + "id": client_request_id or str(uuid.uuid4()), + "method": "message/stream", + "params": params, + } + + tools_used: list[dict] = [] + artifacts: list[dict] = [] + + async with httpx.AsyncClient(timeout=_A2A_TIMEOUT) as client: + async with aconnect_sse( + client, "POST", a2a_endpoint, json=payload, headers=headers, + ) as event_source: + event_source.response.raise_for_status() + async for sse in event_source.aiter_sse(): + if not sse.data: + continue + try: + event = json.loads(sse.data) + except (ValueError, TypeError): + continue + + result = event.get("result", {}) + kind = result.get("kind", "") + + # ---- Real-time tool events from the sub-agent ---- + if kind == "status-update": + status_obj = result.get("status", {}) + msg = status_obj.get("message", {}) + + # Try structured framework:* metadata first (new format), + # fall back to parsing __event__ from text (legacy). + msg_meta = msg.get("metadata") or {} + framework_event = msg_meta.get("framework:event") + + if framework_event: + # ---- New: structured metadata path ---- + tool = msg_meta.get("framework:tool", "") + logger.info( + f"_try_stream_delegation: received framework:event={framework_event} " + f"from sub-agent metadata (call_graph={'set' if call_graph else 'None'})" + ) + if call_graph is not None: + if framework_event == "tool_start": + call_graph.add_delegation_sub_event( + "tool_start", tool, + agent_name=msg_meta.get("framework:agent"), + ) + elif framework_event == "tool_end": + call_graph.add_delegation_sub_event( + "tool_end", tool, + agent_name=msg_meta.get("framework:agent"), + tools_used=msg_meta.get("framework:tools_used"), + ) + elif framework_event == "delegation_start": + await _notify_delegation_start( + call_graph, + msg_meta.get("framework:from_agent", ""), + msg_meta.get("framework:to_agent", ""), + message=msg_meta.get("framework:message", ""), + ) + elif framework_event == "delegation_end": + await _notify_delegation_end( + call_graph, + msg_meta.get("framework:to_agent", ""), + response=msg_meta.get("framework:response", ""), + ) + else: + # ---- Legacy: parse __event__ from text parts ---- + for part in msg.get("parts", []): + text = part.get("text", "") + if text.startswith('{"__event__"'): + try: + evt = json.loads(text) + etype = evt.get("__event__") + tool = evt.get("tool", "") + logger.info( + f"_try_stream_delegation: received __event__={etype} " + f"from sub-agent SSE (call_graph={'set' if call_graph else 'None'})" + ) + if call_graph is None: + continue + if etype == "tool_start": + call_graph.add_delegation_sub_event( + "tool_start", tool, + agent_name=evt.get("agent"), + ) + elif etype == "tool_end": + call_graph.add_delegation_sub_event( + "tool_end", tool, + agent_name=evt.get("agent"), + tools_used=evt.get("tools_used"), + ) + # -- Nested delegation events -- + elif etype == "delegation_start": + await _notify_delegation_start( + call_graph, + evt.get("from_agent", ""), + evt.get("to_agent", ""), + message=evt.get("message", ""), + ) + elif etype == "delegation_end": + await _notify_delegation_end( + call_graph, + evt.get("to_agent", ""), + response=evt.get("response", ""), + ) + except (ValueError, TypeError): + pass + + # ---- Artifact updates ---- + elif kind == "artifact-update": + artifact = result.get("artifact", {}) + artifacts.append(artifact) + aname = artifact.get("name", "") + if aname.endswith("-execution-trace"): + for part in artifact.get("parts", []): + if part.get("kind") == "data": + data = part.get("data", {}) + tools_used = data.get("tools_used", []) + + return tools_used, {"artifacts": artifacts} + + +async def _delegate_impl( + agent_name: str, + message: str, + *, + allowed_sub_agents: list[str], + from_agent_name: str = "unknown", + agent_registry=None, + local_base_url: str = "http://localhost:8000", + remote_agents: list[dict] | None = None, + current_depth: int = 0, + call_graph=None, + client_request_id: str | None = None, + context_headers: dict[str, str] | None = None, +) -> str: + """Core delegation logic — sends an A2A ``message/send`` to a sub-agent. + + Returns a JSON string (the Agents SDK expects function tools to return str). + """ + # Validate that the target agent is in the caller's sub_agents list + if agent_name not in allowed_sub_agents: + return json.dumps({ + "status": "error", + "error": ( + f"Agent '{agent_name}' is not in your sub_agents list. " + f"Allowed sub-agents: {allowed_sub_agents}" + ), + }) + + # Enforce depth limit + if current_depth >= MAX_DELEGATION_DEPTH: + logger.warning( + f"Delegation depth limit reached ({current_depth}/{MAX_DELEGATION_DEPTH}). " + f"Refusing to delegate to '{agent_name}'." + ) + return json.dumps({ + "status": "error", + "error": ( + f"Delegation depth limit reached ({MAX_DELEGATION_DEPTH}). " + "This prevents infinite agent-to-agent loops. " + "Try handling this task directly instead of delegating." + ), + }) + + # Resolve agent name → URL + agent_url = _resolve_agent_url( + agent_name, + agent_registry=agent_registry, + local_base_url=local_base_url, + remote_agents=remote_agents, + ) + if not agent_url: + return json.dumps({ + "status": "error", + "error": ( + f"Agent '{agent_name}' not found. " + f"Make sure it exists as a local or remote agent." + ), + }) + + # Build A2A request parameters + a2a_endpoint = f"{agent_url.rstrip('/')}/" + + # Immediately show the delegation target in the call graph + if call_graph is not None: + call_graph.update_delegation_target(agent_name) + await _notify_delegation_start( + call_graph, from_agent_name, agent_name, message=message + ) + + params: dict[str, Any] = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": message}], + "messageId": str(uuid.uuid4()), + } + } + + # Cascade context headers to sub-agent via A2A metadata so the + # receiving executor can forward them to MCP tools and further + # delegations — the same context the original client provided. + if context_headers: + params.setdefault("metadata", {})["context_headers"] = context_headers + + next_depth = current_depth + 1 + headers = { + "Content-Type": "application/json", + _DEPTH_HEADER: str(next_depth), + } + # Propagate the original client request id so every hop in the + # delegation chain shares the same session identifier. + if client_request_id: + headers["X-A2A-Client-Request-Id"] = client_request_id + + # ---- Try streaming first for real-time sub-agent visibility ---- + try: + stream_tools, stream_result = await _try_stream_delegation( + a2a_endpoint, params, headers, + call_graph=call_graph, + client_request_id=client_request_id, + ) + response: dict[str, Any] = { + "status": "success", + "agent": agent_name, + "result": stream_result, + } + if stream_tools: + response["tools_used"] = stream_tools + delegate_response = _extract_response_text(stream_result) + await _notify_delegation_end( + call_graph, agent_name, response=delegate_response + ) + return json.dumps(response) + except Exception as exc: + logger.warning( + f"Streaming delegation to '{agent_name}' failed ({type(exc).__name__}: {exc}), " + "falling back to sync message/send" + ) + + # ---- Sync fallback (message/send) ---- + payload = { + "jsonrpc": "2.0", + "id": client_request_id or str(uuid.uuid4()), + "method": "message/send", + "params": params, + } + + try: + async with httpx.AsyncClient(timeout=_A2A_TIMEOUT) as client: + resp = await client.post(a2a_endpoint, json=payload, headers=headers) + resp.raise_for_status() + result = resp.json() + + if "error" in result: + return json.dumps({"status": "error", "error": result["error"]}) + + # Extract tool call trace from execution-trace artifact if present + tools_used: list[dict] = [] + task_result = result.get("result", {}) + for artifact in task_result.get("artifacts", []): + aname = artifact.get("name", "") + if aname.endswith("-execution-trace"): + for part in artifact.get("parts", []): + if part.get("kind") == "data": + data = part.get("data", {}) + tools_used = data.get("tools_used", []) + + response: dict[str, Any] = { + "status": "success", + "agent": agent_name, + "result": task_result, + } + if tools_used: + response["tools_used"] = tools_used + + delegate_response = _extract_response_text(task_result) + await _notify_delegation_end( + call_graph, agent_name, response=delegate_response + ) + return json.dumps(response) + + except httpx.TimeoutException: + await _notify_delegation_end( + call_graph, agent_name, response="Request timed out" + ) + return json.dumps({ + "status": "error", + "error": f"Request to agent '{agent_name}' timed out after {_A2A_TIMEOUT}s", + }) + except Exception as e: + logger.error(f"Delegation to '{agent_name}' at {a2a_endpoint} failed: {e}") + await _notify_delegation_end( + call_graph, agent_name, response=f"Error: {e}" + ) + return json.dumps({ + "status": "error", + "error": f"Failed to reach agent '{agent_name}': {e}", + }) + + +def build_delegate_tool( + agent_def, + *, + agent_registry=None, + local_base_url: str = "http://localhost:8000", + remote_agents: list[dict] | None = None, + call_graph=None, + client_request_id: str | None = None, + context_headers: dict[str, str] | None = None, + current_depth: int = 0, +): + """Build a native ``FunctionTool`` for delegating to sub-agents. + + Args: + agent_def: The calling agent's ``AgentDefinition`` (used to read ``sub_agents``). + agent_registry: ``AgentRegistry`` for resolving local agent names. + local_base_url: Base URL of the local A2A server. + remote_agents: List of ``{"url": "...", "name": "..."}`` for remote agents. + client_request_id: Original client JSON-RPC id for session tracking. + context_headers: Client-provided context headers to cascade to sub-agents. + current_depth: Current delegation depth (for depth limit enforcement). + + Returns: + A ``FunctionTool`` instance that can be passed to ``Agent(tools=[...])``. + """ + from agents import FunctionTool + + allowed = list(agent_def.sub_agents) + + async def _on_invoke(ctx, args_json: str) -> str: + """Invoked by the Agents SDK when the LLM calls the delegate tool.""" + args = json.loads(args_json) + return await _delegate_impl( + agent_name=args["agent_name"], + message=args["message"], + allowed_sub_agents=allowed, + from_agent_name=agent_def.name, + agent_registry=agent_registry, + local_base_url=local_base_url, + remote_agents=remote_agents, + current_depth=current_depth, + call_graph=call_graph, + client_request_id=client_request_id, + context_headers=context_headers, + ) + + return FunctionTool( + name="delegate", + description=( + "Delegate a task to a sub-agent by name. " + f"Available sub-agents: {allowed}. " + "Returns the agent's response as JSON." + ), + params_json_schema={ + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": f"Name of the sub-agent to delegate to. Must be one of: {allowed}", + }, + "message": { + "type": "string", + "description": "The task or message to send to the sub-agent.", + }, + }, + "required": ["agent_name", "message"], + "additionalProperties": False, + }, + on_invoke_tool=_on_invoke, + strict_json_schema=True, + ) diff --git a/src/opensensa/framework_tools/delete_agent.py b/src/opensensa/framework_tools/delete_agent.py new file mode 100644 index 0000000..1d44be3 --- /dev/null +++ b/src/opensensa/framework_tools/delete_agent.py @@ -0,0 +1,89 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: delete_agent — removes an agent .md file from the agents directory.""" + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger("opensensa.framework_tools") + + +def register(mcp, *, agents_directory: str | Path | None = None): + """Register the delete_agent tool. + + Args: + mcp: The MCP server instance. + agents_directory: Path where agent .md files are stored. + """ + + @mcp.tool( + title="Delete Agent", + description=( + "Delete an existing AI agent by removing its .md definition file. " + "This is irreversible — the agent will no longer be available. " + "The change takes effect immediately without restart." + ), + tags=["framework", "meta", "agent-management"], + ) + def delete_agent( + name: str, + confirm: bool = False, + ) -> dict[str, Any]: + """Delete an agent .md file from the agents directory. + + Args: + name: Name of the agent to delete (must already exist). + confirm: Must be True to actually delete. Safety guard against accidental deletion. + + Returns: + Confirmation dict or error. + """ + if not agents_directory: + return {"error": "Agents directory not configured"} + + agents_dir = Path(agents_directory).resolve() + file_path = agents_dir / f"{name}.md" + + if not file_path.exists(): + return {"error": f"Agent '{name}' not found at {file_path}"} + + if not confirm: + return { + "status": "confirmation_required", + "name": name, + "file_path": str(file_path), + "message": ( + f"Are you sure you want to delete agent '{name}'? " + f"This will remove {file_path}. " + f"Call delete_agent again with confirm=True to proceed." + ), + } + + try: + file_path.unlink() + except Exception as e: + return {"error": f"Failed to delete agent file: {e}"} + + logger.info(f"Deleted agent: {name} at {file_path}") + + return { + "status": "deleted", + "name": name, + "file_path": str(file_path), + "message": f"Agent '{name}' has been deleted.", + } diff --git a/src/opensensa/framework_tools/discover_agents.py b/src/opensensa/framework_tools/discover_agents.py new file mode 100644 index 0000000..bf51ead --- /dev/null +++ b/src/opensensa/framework_tools/discover_agents.py @@ -0,0 +1,98 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: discover_agents — fetches A2A Agent Cards from remote + local agents.""" + +import logging +from typing import Any + +import httpx + +logger = logging.getLogger("opensensa.framework_tools") + + +def register( + mcp, + *, + agent_registry=None, + remote_agents: list[dict] | None = None, + local_base_url: str = "http://localhost:8000", +): + """Register the discover_agents tool. + + Args: + mcp: The MCP server instance to register against. + agent_registry: AgentRegistry instance for local agents. + remote_agents: List of {"url": "..."} dicts for remote A2A agents. + local_base_url: Base URL of the local A2A server for building local agent URLs. + """ + + @mcp.tool( + title="Discover Agents", + description=( + "Discover available AI agents — both local and remote. Returns agent names, " + "descriptions, skills, and capabilities. Use this to find agents that can " + "handle specific tasks before delegating with send_to_agent." + ), + tags=["framework", "a2a", "discovery"], + ) + async def discover_agents() -> list[dict[str, Any]]: + """Fetch A2A Agent Cards from local registry and remote URLs.""" + agents: list[dict[str, Any]] = [] + + # Local agents from filesystem — each has a URL at /agents/{name} + if agent_registry: + for defn in agent_registry.list_agents(): + agents.append({ + "name": defn.name, + "description": defn.description, + "skills": [ + {"id": s.id, "name": s.name, "description": s.description, "tags": s.tags} + for s in defn.skills + ], + "source": "local", + "url": f"{local_base_url.rstrip('/')}/agents/{defn.name}", + }) + + # Remote agents via A2A Agent Card discovery + for entry in (remote_agents or []): + url = entry.get("url", "").rstrip("/") + if not url: + continue + card_url = f"{url}/.well-known/agent-card.json" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(card_url) + resp.raise_for_status() + card = resp.json() + agents.append({ + "name": card.get("name", "unknown"), + "description": card.get("description", ""), + "skills": card.get("skills", []), + "source": "remote", + "url": url, + }) + except Exception as e: + logger.warning(f"Failed to fetch Agent Card from {card_url}: {e}") + agents.append({ + "name": f"unreachable ({url})", + "description": f"Could not fetch Agent Card: {e}", + "skills": [], + "source": "remote", + "url": url, + }) + + return agents diff --git a/src/opensensa/framework_tools/edit_agent.py b/src/opensensa/framework_tools/edit_agent.py new file mode 100644 index 0000000..44f8553 --- /dev/null +++ b/src/opensensa/framework_tools/edit_agent.py @@ -0,0 +1,122 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: edit_agent — modifies an existing agent .md file.""" + +import logging +import re +from pathlib import Path +from typing import Any, Optional + +import frontmatter +import yaml + +logger = logging.getLogger("opensensa.framework_tools") + + +def register(mcp, *, agents_directory: str | Path | None = None): + """Register the edit_agent tool. + + Args: + mcp: The MCP server instance. + agents_directory: Path where agent .md files are stored. + """ + + @mcp.tool( + title="Edit Agent", + description=( + "Edit an existing AI agent's definition. You can update its description, " + "model, tools, and/or system prompt. Only the fields you provide will be " + "changed — omitted fields are left as-is. The agent picks up changes " + "immediately without restart." + ), + tags=["framework", "meta", "agent-management"], + ) + def edit_agent( + name: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + model: Optional[str] = None, + tools: Optional[list[str]] = None, + sub_agents: Optional[list[str]] = None, + ) -> dict[str, Any]: + """Edit an existing agent .md file in the agents directory. + + Args: + name: Name of the agent to edit (must already exist). + description: New one-line description (or None to keep current). + system_prompt: New system prompt / markdown body (or None to keep current). + model: New model reference (or None to keep current). + tools: New list of tool names (or None to keep current). + sub_agents: New list of sub-agent names (or None to keep current). + + Returns: + Confirmation dict with file path and updated fields, or error. + """ + if not agents_directory: + return {"error": "Agents directory not configured"} + + agents_dir = Path(agents_directory).resolve() + file_path = agents_dir / f"{name}.md" + + if not file_path.exists(): + return {"error": f"Agent '{name}' not found at {file_path}"} + + # Parse existing file + try: + post = frontmatter.load(str(file_path)) + except Exception as e: + return {"error": f"Failed to parse agent file: {e}"} + + meta = post.metadata + updated_fields: list[str] = [] + + # Apply updates — only for fields that were explicitly provided + if description is not None: + meta["description"] = description + updated_fields.append("description") + if model is not None: + meta["model"] = model + updated_fields.append("model") + if tools is not None: + meta["tools"] = tools + updated_fields.append("tools") + if sub_agents is not None: + meta["sub_agents"] = sub_agents + updated_fields.append("sub_agents") + + body = post.content.strip() + if system_prompt is not None: + body = system_prompt.strip() + updated_fields.append("system_prompt") + + if not updated_fields: + return {"status": "no_changes", "name": name, "message": "No fields were provided to update."} + + # Rebuild the file + yaml_block = yaml.dump(meta, default_flow_style=False, sort_keys=False).strip() + content = f"---\n{yaml_block}\n---\n\n{body}\n" + + file_path.write_text(content, encoding="utf-8") + logger.info(f"Edited agent: {name} at {file_path} — updated: {updated_fields}") + + return { + "status": "updated", + "name": name, + "file_path": str(file_path), + "updated_fields": updated_fields, + "message": f"Agent '{name}' has been updated ({', '.join(updated_fields)}).", + } diff --git a/src/opensensa/framework_tools/list_tools.py b/src/opensensa/framework_tools/list_tools.py new file mode 100644 index 0000000..a9080c6 --- /dev/null +++ b/src/opensensa/framework_tools/list_tools.py @@ -0,0 +1,63 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: list_tools — returns all registered MCP tools with metadata.""" + +import logging +from typing import Any + +logger = logging.getLogger("opensensa.framework_tools") + + +def register(mcp): + """Register the list_tools tool. + + Args: + mcp: The MCP server instance (LoggedMCP or FastMCP). + """ + + @mcp.tool( + title="List Tools", + description=( + "List all available MCP tools with their names, descriptions, and tags. " + "Use this to discover what capabilities are available before creating agents " + "or deciding which tools to use." + ), + tags=["framework", "meta", "introspection"], + ) + def list_tools() -> list[dict[str, Any]]: + """Return metadata for all registered MCP tools.""" + tools_list: list[dict[str, Any]] = [] + + # If it's a LoggedMCP wrapper, use its registered_tools property + if hasattr(mcp, "registered_tools"): + for tool_name, meta in mcp.registered_tools.items(): + tools_list.append({ + "name": meta.get("function", tool_name), + "title": meta.get("title", tool_name), + "description": meta.get("description", ""), + "tags": meta.get("tags", []), + }) + else: + # Fallback: just report that introspection isn't available + tools_list.append({ + "name": "list_tools", + "title": "List Tools", + "description": "Tool introspection (limited: raw FastMCP instance)", + "tags": ["framework"], + }) + + return tools_list diff --git a/src/opensensa/framework_tools/send_to_agent.py b/src/opensensa/framework_tools/send_to_agent.py new file mode 100644 index 0000000..0232812 --- /dev/null +++ b/src/opensensa/framework_tools/send_to_agent.py @@ -0,0 +1,148 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Framework tool: send_to_agent — sends A2A SendMessage to a remote agent.""" + +import json +import logging +from typing import Any, Optional + +import httpx + +logger = logging.getLogger("opensensa.framework_tools") + +# Maximum delegation depth to prevent infinite loops +MAX_A2A_DEPTH = 5 + +# Header used to propagate current delegation depth +_DEPTH_HEADER = "X-A2A-Depth" + + +def register(mcp): + """Register the send_to_agent tool.""" + + @mcp.tool( + title="Send Message to Agent", + description=( + "Send a message to another AI agent via the A2A protocol. Use discover_agents " + "first to find the right agent and its URL, then use this tool to delegate a task. " + "Returns the agent's response as an A2A Task with artifacts." + ), + tags=["framework", "a2a", "delegation"], + ) + async def send_to_agent( + agent_url: str, + message: str, + task_id: Optional[str] = None, + blocking: bool = True, + current_depth: int = 0, + client_request_id: Optional[str] = None, + context_headers: Optional[dict] = None, + ) -> dict[str, Any]: + """Send an A2A SendMessage JSON-RPC request to a remote agent. + + Args: + agent_url: Base URL of the target agent (e.g. http://localhost:9000). + message: The text message to send to the agent. + task_id: Optional existing task ID for multi-turn conversations. + blocking: If True, wait for the task to complete. If False, return immediately. + current_depth: Current delegation depth (auto-managed by framework). + client_request_id: Original client JSON-RPC id for session tracking. + context_headers: Context headers to cascade to the target agent. + + Returns: + A2A Task response with status and artifacts. + """ + # Enforce depth limit to prevent infinite delegation loops + if current_depth >= MAX_A2A_DEPTH: + logger.warning( + f"A2A delegation depth limit reached ({current_depth}/{MAX_A2A_DEPTH}). " + f"Refusing to delegate to {agent_url}." + ) + return { + "status": "error", + "error": ( + f"Delegation depth limit reached ({MAX_A2A_DEPTH}). " + "This prevents infinite agent-to-agent loops. " + "Try handling this task directly instead of delegating." + ), + } + + url = agent_url.rstrip("/") + # POST to the agent's root — this is the JSON-RPC endpoint + # for per-agent sub-apps mounted at /agents/{name}/ + a2a_endpoint = f"{url}/" + + # Build JSON-RPC SendMessage request + import uuid + + params: dict[str, Any] = { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": message}], + "messageId": str(uuid.uuid4()), + } + } + if task_id: + params["message"]["taskId"] = task_id + + # Cascade context headers to sub-agent via A2A metadata + if context_headers: + params.setdefault("metadata", {})["context_headers"] = context_headers + + payload = { + "jsonrpc": "2.0", + "id": client_request_id or str(uuid.uuid4()), + "method": "message/send", + "params": params, + } + + # Propagate depth via header so the receiving agent can track it + next_depth = current_depth + 1 + headers = { + "Content-Type": "application/json", + _DEPTH_HEADER: str(next_depth), + } + # Propagate the original client request id + if client_request_id: + headers["X-A2A-Client-Request-Id"] = client_request_id + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + a2a_endpoint, + json=payload, + headers=headers, + ) + resp.raise_for_status() + result = resp.json() + + if "error" in result: + return { + "status": "error", + "error": result["error"], + } + + return { + "status": "success", + "result": result.get("result", {}), + } + + except httpx.TimeoutException: + return {"status": "error", "error": f"Request to {a2a_endpoint} timed out"} + except Exception as e: + logger.error(f"A2A SendMessage to {a2a_endpoint} failed: {e}") + return {"status": "error", "error": str(e)} diff --git a/src/opensensa/interactive/__init__.py b/src/opensensa/interactive/__init__.py new file mode 100644 index 0000000..b50f4f9 --- /dev/null +++ b/src/opensensa/interactive/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Interactive terminal UI for OpenSensa chat.""" diff --git a/src/opensensa/interactive/call_graph.py b/src/opensensa/interactive/call_graph.py new file mode 100644 index 0000000..0833b25 --- /dev/null +++ b/src/opensensa/interactive/call_graph.py @@ -0,0 +1,533 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Live call-graph renderable for the OpenSensa chat TUI. + +Displays a real-time Rich ``Tree`` showing tool calls, LLM invocations, and +agent delegations as they happen during an agent turn. Designed to be used +inside a ``rich.live.Live`` context that auto-refreshes the terminal. + +Usage:: + + graph = CallGraph(agent_name="clinical-researcher") + with Live(graph, console=console, refresh_per_second=8): + # hooks push events into the graph + graph.start_tool("csv_formatter") + graph.end_tool("csv_formatter", duration_ms=320, result="3 rows") + ... + # after Live exits, print the final tree with graph.make_summary() +""" + +from __future__ import annotations + +import json as _json +import time +from dataclasses import dataclass, field +from typing import Optional + +from rich.console import Console, ConsoleOptions, RenderResult +from rich.panel import Panel +from rich.text import Text +from rich.tree import Tree + + +# --------------------------------------------------------------------------- +# Data model — one node per tool/delegation/LLM call +# --------------------------------------------------------------------------- + +@dataclass +class _CallNode: + """A single node in the call graph.""" + + label: str + kind: str # "tool", "delegation", "llm", "agent" + start_time: float = field(default_factory=time.monotonic) + end_time: Optional[float] = None + duration_ms: Optional[int] = None + result_preview: Optional[str] = None + children: list["_CallNode"] = field(default_factory=list) + status: str = "running" # running | completed | failed + + def finish( + self, + *, + duration_ms: int | None = None, + result_preview: str | None = None, + status: str = "completed", + ) -> None: + self.end_time = time.monotonic() + self.duration_ms = duration_ms or int((self.end_time - self.start_time) * 1000) + self.result_preview = result_preview + self.status = status + + +# --------------------------------------------------------------------------- +# Style helpers +# --------------------------------------------------------------------------- + +_KIND_ICONS = { + "tool": "🔧", + "delegation": "🤖", + "llm": "💬", + "agent": "🏷️ ", +} + +_STATUS_STYLE = { + "running": "yellow", + "completed": "green", + "failed": "red", +} + +SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + +def _render_node(node: _CallNode, frame: int = 0) -> Text: + """Build a Rich ``Text`` for a single call node.""" + icon = _KIND_ICONS.get(node.kind, "●") + style = _STATUS_STYLE.get(node.status, "dim") + + parts = Text() + parts.append(f"{icon} ", style=style) + parts.append(node.label, style=f"bold {style}") + + if node.status == "running": + spinner = SPINNER_FRAMES[frame % len(SPINNER_FRAMES)] + parts.append(f" {spinner}", style="yellow") + elif node.duration_ms is not None: + parts.append(f" ({node.duration_ms}ms)", style="dim") + + if node.status == "completed" and node.result_preview: + preview = node.result_preview[:80].replace("\n", " ") + parts.append(f" → {preview}", style="dim") + + if node.status == "completed": + parts.append(" ✓", style="green") + elif node.status == "failed": + parts.append(" ✗", style="red") + + return parts + + +# --------------------------------------------------------------------------- +# CallGraph — the main renderable +# --------------------------------------------------------------------------- + +class CallGraph: + """A live-updating call tree that tracks agent activity during a turn. + + This class is both a data collector (hooks push events into it) and a + Rich renderable (``Live`` calls ``__rich_console__`` to paint it). + """ + + def __init__(self, agent_name: str) -> None: + self.agent_name = agent_name + self._root_nodes: list[_CallNode] = [] + self._active_stack: list[_CallNode] = [] + self._turn_start: float = time.monotonic() + self._total_tokens: int = 0 + self._llm_calls: int = 0 + self._tool_calls: int = 0 + self._delegations: int = 0 + # Key → node lookup. Keys use a sequence counter to stay unique + # even when the same tool is called multiple times in one turn. + self._node_map: dict[str, _CallNode] = {} + self._key_seq: int = 0 + self._frame: int = 0 + + # -- Event methods (called from hooks) ---------------------------------- + + def _next_key(self, prefix: str) -> str: + """Return a unique map key like ``tool:delegate:3``.""" + self._key_seq += 1 + return f"{prefix}:{self._key_seq}" + + def _find_running(self, prefix: str) -> _CallNode | None: + """Find the most recent *running* node whose key starts with *prefix*.""" + # Iterate in reverse insertion order (Python 3.7+ dict guarantee) + for key in reversed(list(self._node_map)): + if key.startswith(prefix + ":"): + node = self._node_map[key] + if node.status == "running": + return node + return None + + # -- Event methods (called from hooks) ---------------------------------- + + def start_llm(self, agent_name: str | None = None) -> None: + """Record the start of an LLM call.""" + label = f"LLM → {agent_name}" if agent_name else "LLM call" + node = _CallNode(label=label, kind="llm") + self._add_node(node, key=self._next_key("llm")) + self._llm_calls += 1 + + def end_llm( + self, + agent_name: str | None = None, + duration_ms: int = 0, + tokens: int = 0, + ) -> None: + """Record the end of an LLM call.""" + node = self._find_running("llm") + if node: + result = f"{tokens} tokens" if tokens else None + node.finish(duration_ms=duration_ms, result_preview=result) + self._total_tokens += tokens + # Pop from active stack if it's the top + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + + def start_tool(self, tool_name: str) -> None: + """Record the start of a tool call.""" + if tool_name == "delegate": + # Show as a delegation in progress so it doesn't look like a glitch + node = _CallNode(label="delegating\u2026", kind="delegation") + self._add_node(node, key=self._next_key("tool")) + self._delegations += 1 + else: + node = _CallNode(label=tool_name, kind="tool") + self._add_node(node, key=self._next_key("tool")) + self._tool_calls += 1 + + def end_tool( + self, + tool_name: str, + duration_ms: int = 0, + result: str | None = None, + ) -> None: + """Record the end of a tool call.""" + # Find the most recent running tool node. For the delegate tool + # we match by kind since its label changes during execution. + if tool_name == "delegate": + node = self._find_running("tool") + # Verify it's actually a delegation node + if node and node.kind != "delegation": + node = None + else: + node = self._find_running("tool") + # Verify the label matches the tool name + if node and node.label != tool_name: + node = None + if not node: + return + + # Special handling for the delegate tool — parse JSON result to + # show the target agent name and any sub-agent tool calls. + if tool_name == "delegate" and result: + self._finish_delegate_node(node, result, duration_ms) + else: + preview = result[:80].replace("\n", " ") if result else None + node.finish(duration_ms=duration_ms, result_preview=preview) + + # Pop from active stack if it's the top + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + + def start_delegation(self, from_agent: str, to_agent: str, **kwargs) -> None: + """Record the start of an agent-to-agent delegation. + + If ``start_tool("delegate")`` already created a placeholder node we + *merge* into it (update the label, push it onto the active stack) so + the tree only shows one entry per delegation. + """ + # Try to merge with the placeholder created by start_tool("delegate"). + # Only merge if it hasn't been merged yet — a nested delegation + # (B→C inside A→B) must NOT overwrite the parent node. + existing = self._find_running("tool") + if existing and existing.kind == "delegation" and not getattr(existing, '_merged', False): + existing.label = f"{from_agent} → {to_agent}" + existing._merged = True # type: ignore[attr-defined] + # Push as new context — subsequent calls nest under this delegation + self._active_stack.append(existing) + # Don't increment _delegations — already counted by start_tool + return + + # Standalone delegation (no prior start_tool("delegate") call) + label = f"{from_agent} → {to_agent}" + node = _CallNode(label=label, kind="delegation") + self._add_node(node, key=self._next_key("delegation")) + self._active_stack.append(node) + self._delegations += 1 + + def end_delegation(self, to_agent: str, duration_ms: int = 0, **kwargs) -> None: + """Record the end of an agent-to-agent delegation.""" + # Pop the delegation from the active stack + if self._active_stack and self._active_stack[-1].kind == "delegation": + node = self._active_stack.pop() + # If merged with a tool node, don't finish here — + # end_tool("delegate") will handle finishing with result parsing. + if not getattr(node, '_merged', False): + node.finish(duration_ms=duration_ms) + return + + # Fallback: find by key prefix (standalone delegation node) + node = self._find_running("delegation") + if node: + node.finish(duration_ms=duration_ms) + + def start_agent(self, agent_name: str) -> None: + """Record an agent lifecycle start (for sub-agents during delegation).""" + # Only show agent nodes when it's different from the root agent + if agent_name != self.agent_name: + node = _CallNode(label=agent_name, kind="agent") + self._add_node(node, key=self._next_key("agent")) + + def end_agent(self, agent_name: str, duration_ms: int = 0) -> None: + """Record an agent lifecycle end.""" + node = self._find_running("agent") + if node: + node.finish(duration_ms=duration_ms) + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + + def update_delegation_target(self, agent_name: str) -> None: + """Update the in-progress delegation node with the target agent name. + + Called by the delegate tool as soon as it knows which agent is + being delegated to — before waiting for the response. Skipped + when ``start_delegation`` already set a proper label. + """ + node = self._find_running("tool") + if node and node.kind == "delegation" and not getattr(node, '_merged', False): + node.label = f"delegate \u2192 {agent_name}" + + def add_delegation_sub_event( + self, + event_type: str, + tool_name: str, + *, + agent_name: str | None = None, + tools_used: list[dict] | None = None, + ) -> None: + """Push a real-time sub-agent tool event into the active delegation. + + The delegate tool calls this when it receives SSE events from the + sub-agent's streaming execution, so the user can see what the + sub-agent is doing *as it happens*. + """ + # Find the active delegation node (the running tool:* with kind=delegation) + node = self._find_running("tool") + if not node or node.kind != "delegation": + return + + # Skip delegate tool events — nested delegations are now handled + # by start_delegation/end_delegation via Phase 14 event forwarding. + if tool_name == "delegate": + return + + if event_type == "tool_start": + child = _CallNode(label=tool_name, kind="tool") + node.children.append(child) + + elif event_type == "tool_end": + # Find the latest running child that matches this tool + for child in reversed(node.children): + if child.status != "running": + continue + if child.label == tool_name and child.kind == "tool": + child.finish() + break + + # -- Internal helpers --------------------------------------------------- + + def _add_node(self, node: _CallNode, key: str) -> None: + """Add a node either as a child of the active context or at the root.""" + self._node_map[key] = node + if self._active_stack: + self._active_stack[-1].children.append(node) + else: + self._root_nodes.append(node) + + def _finish_delegate_node( + self, node: _CallNode, result_json: str, duration_ms: int + ) -> None: + """Parse a delegate tool result and update the node nicely. + + Instead of showing raw JSON, this extracts the target agent name + and any tool calls the sub-agent made, rendering them as children. + """ + try: + data = _json.loads(result_json) + except (ValueError, TypeError): + node.finish(duration_ms=duration_ms, result_preview=result_json[:80]) + return + + agent_name = data.get("agent", "") + status = data.get("status", "unknown") + + # Update label to show target agent (skip if start_delegation + # already set the proper "A → B" label via merge). + if agent_name and not getattr(node, '_merged', False): + node.label = f"delegate → {agent_name}" + node.kind = "delegation" + + # Add child nodes for each tool the sub-agent called. + # If the node already has real-time children (from event forwarding + # during a merged delegation), keep them — they have richer detail. + tools_used = data.get("tools_used", []) + if tools_used and not node.children: + self._add_tool_children(node, tools_used) + + # Set a clean result preview + if status == "success": + preview = "completed" + node.finish(duration_ms=duration_ms, result_preview=preview) + elif status == "error": + error_msg = data.get("error", "unknown error")[:60] + node.finish( + duration_ms=duration_ms, + result_preview=error_msg, + status="failed", + ) + else: + node.finish(duration_ms=duration_ms) + + def _add_tool_children( + self, parent: _CallNode, tools_used: list[dict] + ) -> None: + """Recursively add child nodes from a ``tools_used`` list. + + Each entry may be a plain ``{"name": "..."}`` or a nested + delegation with ``{"name": "delegate", "agent": "...", + "tools_used": [...]}``. + """ + for tool_info in tools_used: + tname = tool_info.get("name", "unknown") + agent = tool_info.get("agent") + nested = tool_info.get("tools_used", []) + + if tname == "delegate" and agent: + # Nested delegation — show with delegation icon + agent name + child = _CallNode( + label=f"delegate \u2192 {agent}", + kind="delegation", + status="completed", + ) + child.end_time = child.start_time + # Recurse into what that agent called + self._add_tool_children(child, nested) + else: + child = _CallNode( + label=tname, + kind="tool", + status="completed", + ) + child.end_time = child.start_time + + parent.children.append(child) + + # -- Stats -------------------------------------------------------------- + + @property + def elapsed_ms(self) -> int: + return int((time.monotonic() - self._turn_start) * 1000) + + @property + def is_empty(self) -> bool: + return len(self._root_nodes) == 0 + + # -- Rich renderable ---------------------------------------------------- + + def _build_tree(self) -> Tree: + """Build a Rich ``Tree`` from the current call graph state.""" + # Header with agent name and elapsed time + header = Text() + header.append(f" {self.agent_name}", style="bold cyan") + + tree = Tree(header) + + frame = self._frame + + def _add_children(parent_tree: Tree, nodes: list[_CallNode]) -> None: + for node in nodes: + label = _render_node(node, frame) + branch = parent_tree.add(label) + if node.children: + _add_children(branch, node.children) + + _add_children(tree, self._root_nodes) + + # If nothing has happened yet, show a waiting indicator + if not self._root_nodes: + tree.add(Text(" Thinking…", style="dim italic")) + + return tree + + def _build_footer(self) -> Text: + """Status bar with counters.""" + footer = Text() + if self._tool_calls: + footer.append(f" 🔧 {self._tool_calls}", style="yellow") + if self._delegations: + if footer: + footer.append(" │ ", style="dim") + footer.append(f" 🤖 {self._delegations}", style="cyan") + if self._total_tokens: + if footer: + footer.append(" │ ", style="dim") + footer.append(f" ⚡ {self._total_tokens} tok", style="dim") + return footer + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + """Render the call graph as a bordered panel with a tree inside.""" + self._frame += 1 + tree = self._build_tree() + footer = self._build_footer() + + # Combine tree + footer into the panel + width = min(console.width, 72) + + yield Panel( + tree, + subtitle=str(footer) if footer.plain.strip() else None, + subtitle_align="left", + border_style="yellow", + padding=(0, 1), + width=width, + ) + + # -- Post-turn summary (printed after Live exits) ----------------------- + + def make_summary(self) -> Panel: + """Return a static Rich panel summarizing the completed turn. + + Call this after the agent turn finishes (outside of ``Live``) to + print a final snapshot of what happened. + """ + tree = self._build_tree() + + # Build a richer footer for the summary + elapsed_s = self.elapsed_ms / 1000 + summary_parts = [] + if self._tool_calls: + summary_parts.append(f"🔧 {self._tool_calls} tool(s)") + if self._delegations: + summary_parts.append(f"🤖 {self._delegations} delegation(s)") + if self._total_tokens: + summary_parts.append(f"⚡ {self._total_tokens} tokens") + summary_parts.append(f"⏱ {elapsed_s:.1f}s") + + subtitle = " ".join(summary_parts) + + return Panel( + tree, + subtitle=f"[dim]{subtitle}[/dim]", + subtitle_align="left", + border_style="dim yellow", + padding=(0, 1), + width=72, + ) diff --git a/src/opensensa/interactive/chat.py b/src/opensensa/interactive/chat.py new file mode 100644 index 0000000..7a67988 --- /dev/null +++ b/src/opensensa/interactive/chat.py @@ -0,0 +1,785 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Interactive chat session — run agents conversationally in the terminal. + +This module provides the ``ChatSession`` class that powers ``opensensa chat``. +It starts the MCP tool server in the background, builds agents via the +OpenAI Agents SDK, and displays responses with Rich formatting. +""" + +from __future__ import annotations + +import asyncio +import logging +import multiprocessing +import socket +import time +from contextlib import AsyncExitStack +from pathlib import Path +from typing import Any, Optional + +# Disable the OpenAI Agents SDK built-in tracing — it sends telemetry to +# OpenAI's API which fails with non-OpenAI models (e.g. "unknown parameter: +# total_tokens"). OpenSensa has its own tracing skeleton in orchestrator/tracing.py. +try: + from agents.tracing import set_tracing_disabled # type: ignore[import-untyped] + set_tracing_disabled(True) +except ImportError: + pass + +logger = logging.getLogger(__name__) + +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentDefinition, AgentRegistry +from opensensa.interactive.call_graph import CallGraph +from opensensa.interactive.ui import ( + console, + get_spinner, + print_agent_response, + print_agent_selector, + print_agents_list, + print_error, + print_help, + print_info, + print_tool_call, + print_tool_result, + print_tools_list, + print_welcome, +) + + +# --------------------------------------------------------------------------- +# RunHooks → real-time tool-call display +# --------------------------------------------------------------------------- + +def _make_hooks(call_graph: CallGraph | None = None): + """Create RunHooks that update the live call-graph AND emit structured logs. + + When *call_graph* is provided, tool calls, LLM invocations, delegations + and agent lifecycle events are pushed into it so ``Live`` can render + a real-time tree. Structured JSON logs are always emitted regardless. + + Returns ``None`` if the agents SDK doesn't expose RunHooks. + """ + try: + from agents import RunHooks # type: ignore[attr-defined] + + _hook_logger = logging.getLogger("opensensa.hooks") + + class _ChatHooks(RunHooks): + def __init__(self, graph: CallGraph | None = None): + self._graph = graph + self._llm_start_time: float = 0.0 + self._tool_start_times: dict[str, float] = {} + self._agent_start_time: float = 0.0 + + # -- LLM calls --------------------------------------------------- + + async def on_llm_start(self, context, agent, system_prompt, input_items): + self._llm_start_time = time.monotonic() + agent_name = getattr(agent, "name", str(agent)) + if self._graph: + self._graph.start_llm(agent_name) + _structured_log(_hook_logger, "llm_start", { + "agent": agent_name, + "input_items_count": len(input_items) if input_items else 0, + }) + + async def on_llm_end(self, context, agent, response): + duration_ms = int((time.monotonic() - self._llm_start_time) * 1000) + usage = getattr(response, "usage", None) + usage_data = {} + total_tokens = 0 + if usage: + usage_data = { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0), + "requests": getattr(usage, "requests", 0), + } + total_tokens = usage_data["total_tokens"] + cached = getattr(getattr(usage, "input_tokens_details", None), "cached_tokens", 0) + reasoning = getattr(getattr(usage, "output_tokens_details", None), "reasoning_tokens", 0) + if cached: + usage_data["cached_tokens"] = cached + if reasoning: + usage_data["reasoning_tokens"] = reasoning + + agent_name = getattr(agent, "name", str(agent)) + if self._graph: + self._graph.end_llm( + agent_name=agent_name, + duration_ms=duration_ms, + tokens=total_tokens, + ) + + _structured_log(_hook_logger, "llm_end", { + "agent": agent_name, + "duration_ms": duration_ms, + "usage": usage_data, + "response_id": getattr(response, "response_id", None), + }) + + # -- Tools ------------------------------------------------------- + + async def on_tool_start(self, context, agent, tool): + name = getattr(tool, "name", None) or str(tool) + self._tool_start_times[name] = time.monotonic() + if self._graph: + self._graph.start_tool(name) + else: + print_tool_call(name) + _structured_log(_hook_logger, "tool_start", { + "agent": getattr(agent, "name", str(agent)), + "tool": name, + }) + + async def on_tool_end(self, context, agent, tool, result): + name = getattr(tool, "name", None) or str(tool) + duration_ms = int((time.monotonic() - self._tool_start_times.pop(name, time.monotonic())) * 1000) + text = str(result) if result else "" + if self._graph: + # Pass full text — call_graph needs complete JSON for + # delegate results; it handles truncation internally. + self._graph.end_tool(name, duration_ms=duration_ms, result=text) + else: + if len(text) > 200: + text = text[:200] + "…" + print_tool_result(name, text) + _structured_log(_hook_logger, "tool_end", { + "agent": getattr(agent, "name", str(agent)), + "tool": name, + "duration_ms": duration_ms, + "result_length": len(str(result)) if result else 0, + }) + + # -- Agent lifecycle --------------------------------------------- + + async def on_agent_start(self, context, agent): + self._agent_start_time = time.monotonic() + agent_name = getattr(agent, "name", str(agent)) + if self._graph: + self._graph.start_agent(agent_name) + _structured_log(_hook_logger, "agent_start", { + "agent": agent_name, + }) + + async def on_agent_end(self, context, agent, output): + duration_ms = int((time.monotonic() - self._agent_start_time) * 1000) + agent_name = getattr(agent, "name", str(agent)) + if self._graph: + self._graph.end_agent(agent_name, duration_ms=duration_ms) + _structured_log(_hook_logger, "agent_end", { + "agent": agent_name, + "duration_ms": duration_ms, + "output_length": len(str(output)) if output else 0, + }) + + # -- Handoffs (delegation) --------------------------------------- + + async def on_handoff(self, context, from_agent, to_agent): + from_name = getattr(from_agent, "name", str(from_agent)) + to_name = getattr(to_agent, "name", str(to_agent)) + if self._graph: + self._graph.start_delegation(from_name, to_name) + _structured_log(_hook_logger, "handoff", { + "from_agent": from_name, + "to_agent": to_name, + }) + + return _ChatHooks(graph=call_graph) + except ImportError: + # RunHooks not available in this version of the agents SDK + return None + except Exception: + logging.getLogger("opensensa.chat").warning( + "Failed to create RunHooks — tool call display disabled", + exc_info=True, + ) + return None + + +def _structured_log(lgr: logging.Logger, event: str, data: dict) -> None: + """Emit a structured log record with *data* merged into the JSON output.""" + record = lgr.makeRecord( + name=lgr.name, + level=logging.INFO, + fn="", + lno=0, + msg=event, + args=(), + exc_info=None, + ) + record.structured_data = {"event": event, **data} # type: ignore[attr-defined] + lgr.handle(record) + + +# --------------------------------------------------------------------------- +# MCP server background process +# --------------------------------------------------------------------------- + +def _run_mcp_background(config: AppConfig) -> None: + """Entry-point for the background MCP server subprocess.""" + import logging + import os + import sys + + # Silence all logging so it doesn't bleed into the chat UI + logging.disable(logging.CRITICAL) + + # Redirect stdout/stderr to devnull + devnull = open(os.devnull, "w") + sys.stdout = devnull + sys.stderr = devnull + + os.environ.setdefault("DANGEROUSLY_OMIT_AUTH", "true") + + from opensensa.mcp_server.server import create_mcp_server + from opensensa.mcp_server.tool_loader import discover_and_load_tools + + mcp = create_mcp_server( + host=config.server.host, + port=config.server.mcp_port, + ) + + # Framework tools + agents_dir = Path(config.agents.directory).resolve() + registry = AgentRegistry(agents_dir) + remote_agents = [{"url": ra.url} for ra in config.remote_agents] + + # Build local base URL for discover_agents + advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host + local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" + + from opensensa.framework_tools import create_agent, edit_agent, delete_agent, discover_agents, list_tools, send_to_agent + + discover_agents.register( + mcp, + agent_registry=registry, + remote_agents=remote_agents, + local_base_url=local_base_url, + ) + send_to_agent.register(mcp) + # NOTE: delegate is NOT registered on MCP — it is a native FunctionTool + # wired directly into the Agent by agent_builder.py (A2A, not MCP). + create_agent.register(mcp, agents_directory=config.agents.directory) + edit_agent.register(mcp, agents_directory=config.agents.directory) + delete_agent.register(mcp, agents_directory=config.agents.directory) + list_tools.register(mcp) + + # User tools + tools_dir = Path(config.tools.directory).resolve() + discover_and_load_tools(tools_dir, mcp) + + mcp.run(transport="streamable-http") + + +def _run_a2a_background(config: AppConfig) -> None: + """Entry-point for the background A2A server subprocess.""" + import logging + import os + import sys + + # Silence all logging so it doesn't bleed into the chat UI + logging.disable(logging.CRITICAL) + + # Redirect stdout/stderr to devnull + devnull = open(os.devnull, "w") + sys.stdout = devnull + sys.stderr = devnull + + import uvicorn + + from opensensa.orchestrator.agent_registry import AgentRegistry as _Reg + from opensensa.orchestrator.server import create_orchestrator_app + + agents_dir = Path(config.agents.directory).resolve() + registry = _Reg(agents_dir) + registry.scan() + + mcp_server_url = f"http://localhost:{config.server.mcp_port}/mcp" + app = create_orchestrator_app(config, registry, mcp_server_url=mcp_server_url) + + uvicorn.run( + app, + host=config.server.host, + port=config.server.orchestrator_port, + log_level="error", + ) + + +def _wait_for_port(port: int, host: str = "localhost", timeout: float = 20.0) -> None: + """Block until *host:port* accepts a TCP connection.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=1): + return + except (ConnectionRefusedError, TimeoutError, OSError): + time.sleep(0.3) + raise TimeoutError(f"Server on port {port} not ready after {timeout}s") + + +# --------------------------------------------------------------------------- +# Friendly error classification +# --------------------------------------------------------------------------- + +def _handle_chat_error(exc: BaseException) -> None: + """Classify *exc* and display a user-friendly message via Rich. + + Instead of dumping the raw Python exception string, this looks for + common categories (auth, rate-limit, model not found, connection, etc.) + and shows a short actionable message. The full exception is still + logged at DEBUG level for developers. + """ + logger.debug("Agent turn failed", exc_info=exc) + + msg = str(exc).strip() + lower = msg.lower() + + # --- authentication / key --- + if any(kw in lower for kw in ("401", "unauthorized", "api key", "authentication")): + print_error( + "Authentication failed — check your API key.\n" + f" [dim]{_truncate(msg)}[/dim]" + ) + return + + # --- rate limit --- + if any(kw in lower for kw in ("429", "rate limit", "rate_limit", "quota")): + print_error( + "Rate-limited by the provider — wait a moment and try again.\n" + f" [dim]{_truncate(msg)}[/dim]" + ) + return + + # --- model not found --- + if any(kw in lower for kw in ("model_not_found", "model not found", "does not exist")): + print_error( + "Model not found — check the model name in your agent or config.\n" + f" [dim]{_truncate(msg)}[/dim]" + ) + return + + # --- connection errors --- + if any(kw in lower for kw in ("connection", "timed out", "timeout", "unreachable", "dns")): + print_error( + "Connection error — unable to reach the LLM provider.\n" + f" [dim]{_truncate(msg)}[/dim]" + ) + return + + # --- content filter / safety --- + if any(kw in lower for kw in ("content_filter", "content filter", "content_policy", "safety")): + print_error( + "The request was blocked by the provider's content filter." + ) + return + + # --- fallback: show a trimmed message --- + print_error(_truncate(msg, max_len=300)) + + +def _truncate(text: str, max_len: int = 200) -> str: + """Shorten *text* to at most *max_len* characters.""" + if len(text) <= max_len: + return text + return text[:max_len] + "…" + + +# --------------------------------------------------------------------------- +# Chat session +# --------------------------------------------------------------------------- + +class ChatSession: + """Manages the interactive conversation loop with a single agent.""" + + def __init__( + self, + config: AppConfig, + agent_def: AgentDefinition, + registry: AgentRegistry, + ): + self.config = config + self.agent_def = agent_def + self.registry = registry + self.mcp_server_url = f"http://localhost:{config.server.mcp_port}/mcp" + self.conversation_history: list[Any] = [] + # Hooks and call_graph are created per-turn (see run_turn) + self._hooks = None + self._call_graph: CallGraph | None = None + self._mcp_proc: Optional[multiprocessing.Process] = None + self._a2a_proc: Optional[multiprocessing.Process] = None + + # -- MCP + A2A lifecycle ------------------------------------------------- + + def _start_mcp(self) -> None: + self._mcp_proc = multiprocessing.Process( + target=_run_mcp_background, + args=(self.config,), + daemon=True, + ) + self._mcp_proc.start() + _wait_for_port(self.config.server.mcp_port) + + def _stop_mcp(self) -> None: + if self._mcp_proc and self._mcp_proc.is_alive(): + self._mcp_proc.terminate() + self._mcp_proc.join(timeout=3) + + def _start_a2a(self) -> None: + """Start the A2A agent server in a background process.""" + self._a2a_proc = multiprocessing.Process( + target=_run_a2a_background, + args=(self.config,), + daemon=True, + ) + self._a2a_proc.start() + _wait_for_port(self.config.server.orchestrator_port) + + def _stop_a2a(self) -> None: + if self._a2a_proc and self._a2a_proc.is_alive(): + self._a2a_proc.terminate() + self._a2a_proc.join(timeout=3) + + # -- Single turn --------------------------------------------------------- + + async def run_turn(self, user_input: str) -> str: + """Send *user_input* to the agent and return its text response.""" + from agents import Runner + from opensensa.orchestrator.tracing import AgentTraceContext + + turn_start = time.monotonic() + _turn_logger = logging.getLogger("opensensa.chat") + + # Reuse the call graph if already set by the main loop (for Live display), + # otherwise create a fresh one (e.g. if run_turn is called standalone). + if self._call_graph is None: + self._call_graph = CallGraph(agent_name=self.agent_def.name) + self._hooks = _make_hooks(call_graph=self._call_graph) + + # Start a trace for this turn + trace = AgentTraceContext() + span = trace.start_span(self.agent_def.name, event_type="chat_turn") + + async with AsyncExitStack() as exit_stack: + from opensensa.orchestrator.agent_builder import build_agent + + mcp_headers: dict[str, str] = {} + if self.agent_def.context_headers: + mcp_headers.update(self.agent_def.context_headers) + + # Build remote_agents list for delegate tool resolution + remote_agents = [{"url": ra.url, "name": getattr(ra, 'name', '')} for ra in self.config.remote_agents] + + agent = await build_agent( + agent_def=self.agent_def, + config=self.config, + mcp_server_url=self.mcp_server_url, + exit_stack=exit_stack, + mcp_headers=mcp_headers or None, + agent_registry=self.registry, + remote_agents=remote_agents or None, + call_graph=self._call_graph, + ) + + # Build input — plain string for first turn, list for follow-ups + if self.conversation_history: + input_items = self.conversation_history.copy() + input_items.append({"role": "user", "content": user_input}) + run_input: Any = input_items + else: + run_input = user_input + + result = await asyncio.wait_for( + Runner.run( + starting_agent=agent, + input=run_input, + hooks=self._hooks, + max_turns=25, + ), + timeout=300.0, # 5 minutes — matches executor timeout + ) + + # Persist history for multi-turn + self.conversation_history = result.to_input_list() + + # --- Log turn summary --- + turn_duration_ms = int((time.monotonic() - turn_start) * 1000) + usage = getattr(result, "raw_responses", None) + total_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "requests": 0} + if usage: + for resp in usage: + u = getattr(resp, "usage", None) + if u: + total_usage["input_tokens"] += getattr(u, "input_tokens", 0) + total_usage["output_tokens"] += getattr(u, "output_tokens", 0) + total_usage["total_tokens"] += getattr(u, "total_tokens", 0) + total_usage["requests"] += getattr(u, "requests", 0) + + _structured_log(_turn_logger, "turn_complete", { + "agent": self.agent_def.name, + "duration_ms": turn_duration_ms, + "history_length": len(self.conversation_history), + "usage": total_usage, + }) + + # Record usage in trace span and complete it + if total_usage["input_tokens"] or total_usage["output_tokens"]: + span.usage = total_usage + span.complete("completed", output_length=len(str(result.final_output or ""))) + + # Extract text + if result.final_output: + return str(result.final_output) + if result.new_items: + parts = [] + for item in result.new_items: + for attr in ("text", "output"): + if hasattr(item, attr): + parts.append(str(getattr(item, attr))) + break + return "\n".join(parts) if parts else "Agent completed with no output." + return "Agent completed with no output." + + # -- Slash commands ------------------------------------------------------ + + async def handle_command(self, raw: str) -> bool: + """Process a ``/command``. Returns ``False`` when the user wants to quit.""" + parts = raw.strip().split(None, 1) + cmd = parts[0].lower() + + if cmd in ("/quit", "/exit", "/q"): + return False + + if cmd == "/help": + print_help() + elif cmd == "/clear": + console.clear() + elif cmd == "/agents": + print_agents_list(self.registry.list_agents()) + elif cmd == "/tools": + print_tools_list(self.agent_def.tools) + elif cmd == "/model": + model_display = self.agent_def.model + try: + from opensensa.config import resolve_model as _resolve_model + resolved = _resolve_model(self.config, model_display) + model_display = resolved.model_name + except Exception: + pass + print_info(f"Model: [bold]{model_display}[/bold]") + elif cmd == "/history": + n = len(self.conversation_history) + print_info(f"Conversation history: [bold]{n}[/bold] item(s)") + elif cmd == "/reset": + self.conversation_history = [] + print_info("[green]Conversation reset.[/green]") + elif cmd == "/agent": + if len(parts) < 2: + print_error("Usage: /agent ") + return True + new_name = parts[1].strip() + new_def = self.registry.get(new_name) + if new_def: + self.agent_def = new_def + self.conversation_history = [] + console.print( + f"\n [green]✓[/green] Switched to " + f"[bold cyan]{new_name}[/bold cyan] ({new_def.description})\n" + ) + else: + print_error( + f"Agent '{new_name}' not found. " + f"Available: {', '.join(self.registry.agent_names())}" + ) + else: + print_error(f"Unknown command: {cmd} — type /help for commands.") + return True + + # -- Main loop ----------------------------------------------------------- + + async def start(self) -> None: + """Show the welcome banner, start MCP, enter the chat loop.""" + + # Resolve ${default} model token for display + display_model = self.agent_def.model + try: + from opensensa.config import resolve_model as _resolve_model + + resolved = _resolve_model(self.config, display_model) + display_model = resolved.model_name + except Exception: + pass + + print_welcome( + agent_name=self.agent_def.name, + description=self.agent_def.description, + model=display_model, + tools=self.agent_def.tools, + ) + + # Start the MCP tool server if the agent declares tools + if self.agent_def.tools: + try: + console.print(" [dim]Starting tool server…[/dim]") + self._start_mcp() + console.print(" [green]✓[/green] [dim]Tool server ready[/dim]\n") + except Exception as exc: + print_error(f"Could not start tool server: {exc}") + console.print(" [dim]Continuing without tools.[/dim]\n") + + # Start the A2A agent server so agents can call each other + try: + console.print(" [dim]Starting A2A agent server…[/dim]") + self._start_a2a() + agent_names = self.registry.agent_names() + console.print( + f" [green]✓[/green] [dim]A2A server ready " + f"({len(agent_names)} agent(s) at localhost:{self.config.server.orchestrator_port})[/dim]\n" + ) + except Exception as exc: + print_error(f"Could not start A2A server: {exc}") + console.print(" [dim]Agent-to-agent calls will not work.[/dim]\n") + + # -- Input setup (prefer prompt_toolkit for history) ----------------- + prompt_session = None + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.formatted_text import HTML + + prompt_session = PromptSession( + message=HTML("> "), + ) + except ImportError: + pass + + loop = asyncio.get_running_loop() + + try: + while True: + # --- read user input --- + try: + if prompt_session is not None: + user_input: str = await loop.run_in_executor( + None, prompt_session.prompt + ) + else: + user_input = await loop.run_in_executor( + None, lambda: console.input("[bold green]>[/bold green] ") + ) + user_input = user_input.strip() + except (EOFError, KeyboardInterrupt): + break + + if not user_input: + continue + + # --- slash commands --- + if user_input.startswith("/"): + if not await self.handle_command(user_input): + break + continue + + # --- agent turn --- + console.print() + try: + from rich.live import Live + + # Create a fresh call graph for this turn; + # run_turn() will attach it and create hooks. + call_graph = CallGraph(agent_name=self.agent_def.name) + self._call_graph = call_graph + + with Live( + call_graph, + console=console, + refresh_per_second=8, + transient=True, # clear the live display when done + ): + response = await self.run_turn(user_input) + + # Print the final call graph summary if there were + # any tool calls or delegations (skip for pure LLM turns) + if not call_graph.is_empty: + console.print(call_graph.make_summary()) + + # Reset for next turn + self._call_graph = None + + print_agent_response(response, self.agent_def.name) + except KeyboardInterrupt: + console.print("\n [dim]Interrupted.[/dim]\n") + except Exception as exc: + _handle_chat_error(exc) + finally: + self._stop_mcp() + self._stop_a2a() + console.print("\n [dim]Goodbye![/dim]\n") + + +# --------------------------------------------------------------------------- +# Top-level entry-point (called from CLI) +# --------------------------------------------------------------------------- + +async def start_chat( + config: AppConfig, + registry: AgentRegistry, + agent_name: str | None = None, +) -> None: + """Launch the interactive chat with agent selection.""" + agents = registry.list_agents() + + if not agents: + print_error( + "No agents found. Run [bold]opensensa init[/bold] first to create a project." + ) + return + + # --- pick an agent --- + if agent_name: + agent_def = registry.get(agent_name) + if not agent_def: + print_error( + f"Agent '{agent_name}' not found. " + f"Available: {', '.join(registry.agent_names())}" + ) + return + elif len(agents) == 1: + agent_def = agents[0] + else: + # Interactive selector + print_agent_selector(agents) + try: + choice = console.input("[bold green]>[/bold green] ").strip() + except (EOFError, KeyboardInterrupt): + return + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(agents): + agent_def = agents[idx] + else: + print_error("Invalid selection.") + return + else: + agent_def = registry.get(choice) + if not agent_def: + print_error(f"Agent '{choice}' not found.") + return + + session = ChatSession(config=config, agent_def=agent_def, registry=registry) + await session.start() diff --git a/src/opensensa/interactive/ui.py b/src/opensensa/interactive/ui.py new file mode 100644 index 0000000..516917c --- /dev/null +++ b/src/opensensa/interactive/ui.py @@ -0,0 +1,230 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Rich terminal UI components for OpenSensa interactive chat. + +Handles all visual rendering: welcome banner, agent responses (markdown), +tool call indicators, spinners, slash-command help, error display, etc. +""" + +from __future__ import annotations + +from rich import box +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.theme import Theme + +# --------------------------------------------------------------------------- +# Themed console — single instance used everywhere +# --------------------------------------------------------------------------- + +APP_THEME = Theme( + { + "agent.name": "bold cyan", + "tool.name": "bold yellow", + "user.prompt": "bold green", + "info": "dim", + "error": "bold red", + "success": "bold green", + "accent": "cyan", + "dim": "dim", + } +) + +console = Console(theme=APP_THEME, highlight=False) + + +# --------------------------------------------------------------------------- +# Welcome / banner +# --------------------------------------------------------------------------- + + +def print_welcome( + agent_name: str, + description: str, + model: str, + tools: list[str], +) -> None: + """Print the welcome banner with agent info.""" + tools_str = ", ".join(tools[:5]) + if len(tools) > 5: + tools_str += f" (+{len(tools) - 5} more)" + + body = Text() + body.append("opensensa", style="bold cyan") + body.append("\n\n") + body.append(" Agent ", style="dim") + body.append(agent_name, style="bold") + body.append("\n") + body.append(" Model ", style="dim") + body.append(model, style="bold") + body.append("\n") + body.append(" Tools ", style="dim") + body.append(tools_str or "none", style="bold yellow" if tools_str else "dim") + body.append("\n\n") + body.append(f" {description}", style="dim italic") + + console.print() + console.print( + Panel( + body, + border_style="cyan", + padding=(1, 3), + width=min(console.width, 72), + ) + ) + console.print() + console.print( + " [dim]Type a message to chat. Use [bold]/help[/bold] for commands, " + "[bold]/quit[/bold] to exit.[/dim]" + ) + console.print() + + +# --------------------------------------------------------------------------- +# Agent responses +# --------------------------------------------------------------------------- + + +def print_agent_response(text: str, agent_name: str = "Agent") -> None: + """Render the agent's response as rich Markdown inside a panel.""" + console.print() + md = Markdown(text) + console.print( + Panel( + md, + title=f"[bold cyan]{agent_name}[/bold cyan]", + title_align="left", + border_style="cyan", + padding=(1, 2), + width=min(console.width, 80), + ) + ) + console.print() + + +# --------------------------------------------------------------------------- +# Tool call indicators +# --------------------------------------------------------------------------- + + +def print_tool_call(tool_name: str) -> None: + """Show that a tool is being invoked (shown in real-time via hooks).""" + console.print(f" [yellow]●[/yellow] [bold yellow]{tool_name}[/bold yellow]") + + +def print_tool_result(tool_name: str, result: str, max_len: int = 160) -> None: + """Show a truncated tool result below the call indicator.""" + truncated = result[:max_len].replace("\n", " ").strip() + if len(result) > max_len: + truncated += "…" + console.print(f" [dim]└─ {truncated}[/dim]") + + +# --------------------------------------------------------------------------- +# Spinner / status +# --------------------------------------------------------------------------- + + +def get_spinner(message: str = "Thinking"): + """Return a ``console.status()`` context manager that shows a spinner.""" + return console.status(f" [dim]{message}…[/dim]", spinner="dots") + + +# --------------------------------------------------------------------------- +# Error +# --------------------------------------------------------------------------- + + +def print_error(message: str) -> None: + console.print(f"\n [bold red]✖ Error:[/bold red] {message}\n") + + +def print_info(message: str) -> None: + console.print(f"\n [dim]{message}[/dim]\n") + + +# --------------------------------------------------------------------------- +# Slash-command help +# --------------------------------------------------------------------------- + + +def print_help() -> None: + tbl = Table(show_header=False, box=None, padding=(0, 2)) + tbl.add_column(style="bold cyan", min_width=18) + tbl.add_column(style="dim") + tbl.add_row("/help", "Show this help") + tbl.add_row("/quit or Ctrl-D", "Exit chat") + tbl.add_row("/clear", "Clear screen") + tbl.add_row("/agents", "List available agents") + tbl.add_row("/agent ", "Switch to a different agent") + tbl.add_row("/tools", "List current agent's tools") + tbl.add_row("/model", "Show current model") + tbl.add_row("/reset", "Clear conversation history") + tbl.add_row("/history", "Show conversation length") + console.print() + console.print( + Panel(tbl, title="[bold]Commands[/bold]", title_align="left", border_style="dim", padding=(1, 1)) + ) + console.print() + + +# --------------------------------------------------------------------------- +# Listing helpers +# --------------------------------------------------------------------------- + + +def print_agents_list(agents) -> None: + """Pretty-print a list of AgentDefinition objects.""" + tbl = Table(box=box.SIMPLE, show_header=True, padding=(0, 2)) + tbl.add_column("Agent", style="bold cyan") + tbl.add_column("Model", style="dim") + tbl.add_column("Description", style="dim") + for a in agents: + tbl.add_row(a.name, a.model, a.description[:55]) + console.print() + console.print(tbl) + console.print() + + +def print_tools_list(tools: list[str]) -> None: + console.print() + if not tools: + console.print(" [dim]No tools configured for this agent.[/dim]") + else: + for t in sorted(tools): + console.print(f" [yellow]●[/yellow] {t}") + console.print() + + +# --------------------------------------------------------------------------- +# Agent selector (when multiple agents exist) +# --------------------------------------------------------------------------- + + +def print_agent_selector(agents) -> None: + """Print a numbered list of agents for the user to pick from.""" + console.print() + console.print(" [bold]Select an agent:[/bold]") + console.print() + for i, a in enumerate(agents, 1): + console.print( + f" [bold cyan]{i}[/bold cyan] {a.name} [dim]— {a.description[:50]}[/dim]" + ) + console.print() diff --git a/src/opensensa/mcp_server/__init__.py b/src/opensensa/mcp_server/__init__.py new file mode 100644 index 0000000..1f17d8b --- /dev/null +++ b/src/opensensa/mcp_server/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""MCP tool server — FastMCP with LoggedMCP and tool auto-discovery.""" diff --git a/src/opensensa/mcp_server/server.py b/src/opensensa/mcp_server/server.py new file mode 100644 index 0000000..0e110d0 --- /dev/null +++ b/src/opensensa/mcp_server/server.py @@ -0,0 +1,82 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""MCP tool server — FastMCP with LoggedMCP wrapper. + +Creates the MCP server instance that tool files register against. +Configured via AppConfig at startup time. +""" + +import os +from typing import Optional + + +# Module-level MCP instance — set by create_mcp_server() +_mcp_instance = None + + +def get_mcp(): + """Get the current MCP server instance. + + Tools import this to register themselves: + from opensensa.mcp_server.server import get_mcp + mcp = get_mcp() + + @mcp.tool(...) + def my_tool(...): + ... + """ + if _mcp_instance is None: + raise RuntimeError( + "MCP server not initialized. Call create_mcp_server() first, " + "or use `opensensa serve` which does this automatically." + ) + return _mcp_instance + + +def create_mcp_server( + host: str = "0.0.0.0", + port: int = 8001, + name: str = "ToolServer", + enable_logging: bool = True, +): + """Create and configure the MCP server instance. + + Args: + host: Bind address. + port: Bind port. + name: Server name shown to MCP clients. + enable_logging: Whether to wrap with LoggedMCP for structured logging. + + Returns: + The LoggedMCP (or raw FastMCP) instance ready for tool registration. + """ + global _mcp_instance + + from mcp.server.fastmcp import FastMCP + from opensensa.utils.logging import LoggedMCP + + # Required for dev/testing without auth + os.environ.setdefault("DANGEROUSLY_OMIT_AUTH", "true") + + raw_mcp = FastMCP(name, host=host, port=port) + + if enable_logging: + _mcp_instance = LoggedMCP(raw_mcp, enable_logging=True) + else: + _mcp_instance = raw_mcp + + return _mcp_instance diff --git a/src/opensensa/mcp_server/tool_loader.py b/src/opensensa/mcp_server/tool_loader.py new file mode 100644 index 0000000..2146dc9 --- /dev/null +++ b/src/opensensa/mcp_server/tool_loader.py @@ -0,0 +1,95 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Auto-discovers tool files from a directory and registers them with the MCP server. + +Scans the configured tools directory for .py files, imports them, and any functions +decorated with @mcp.tool() are automatically registered. +""" + +import importlib.util +import logging +import sys +from pathlib import Path + +logger = logging.getLogger("opensensa.tools") + + +def discover_and_load_tools(tools_directory: str | Path, mcp_instance=None) -> list[str]: + """Import all .py files in tools_directory so their @mcp.tool() decorators fire. + + Supports two tool registration patterns: + 1. Module has a ``register(mcp)`` function — called with the MCP instance. + 2. Module-level ``@mcp.tool()`` decorators that fire on import. + + Args: + tools_directory: Absolute or relative path to the tools folder. + mcp_instance: If provided, passed to ``register(mcp)`` if the module + defines it. Also set as module-level MCP instance so + ``from opensensa.mcp_server.server import get_mcp`` works. + + Returns: + List of tool module names that were successfully loaded. + """ + tools_dir = Path(tools_directory).resolve() + + if not tools_dir.exists(): + logger.warning(f"Tools directory does not exist: {tools_dir}") + return [] + + if not tools_dir.is_dir(): + logger.warning(f"Tools path is not a directory: {tools_dir}") + return [] + + loaded: list[str] = [] + + for py_file in sorted(tools_dir.glob("*.py")): + if py_file.name.startswith("_"): + continue + + module_name = f"user_tools.{py_file.stem}" + + try: + spec = importlib.util.spec_from_file_location(module_name, py_file) + if spec is None or spec.loader is None: + logger.warning(f"Could not create module spec for {py_file}") + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # If the module defines register(mcp), call it with the MCP instance + if mcp_instance and hasattr(module, "register") and callable(module.register): + module.register(mcp_instance) + + loaded.append(py_file.stem) + logger.info(f"Loaded tool module: {py_file.name}") + + except Exception as e: + logger.error(f"Failed to load tool {py_file.name}: {e}") + + logger.info(f"Discovered {len(loaded)} tool module(s) from {tools_dir}") + return loaded + + +def load_bundled_tools(mcp_instance=None) -> list[str]: + """Load the bundled example tools shipped with OpenSensa. + + These live in src/opensensa/tools/ and are always available unless disabled. + """ + bundled_dir = Path(__file__).parent.parent / "tools" + return discover_and_load_tools(bundled_dir, mcp_instance) diff --git a/src/opensensa/orchestrator/__init__.py b/src/opensensa/orchestrator/__init__.py new file mode 100644 index 0000000..fd0a60a --- /dev/null +++ b/src/opensensa/orchestrator/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Orchestrator — A2A server, agent builder, registry, tracing.""" diff --git a/src/opensensa/orchestrator/agent_builder.py b/src/opensensa/orchestrator/agent_builder.py new file mode 100644 index 0000000..db26f5b --- /dev/null +++ b/src/opensensa/orchestrator/agent_builder.py @@ -0,0 +1,154 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Agent builder — constructs OpenAI Agents SDK Agent objects from AgentDefinitions. + +Wires up MCP tool connections, model selection, and system prompts. +Inter-agent delegation is handled via a native ``FunctionTool`` that calls +sub-agents directly over A2A — bypassing MCP entirely (MCP is for tools, +A2A is for agent-to-agent communication). +""" + +import logging +from contextlib import AsyncExitStack +from typing import Any, Optional + +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentDefinition +from opensensa.orchestrator.models import create_model_from_ref + +logger = logging.getLogger("opensensa.orchestrator") + + +async def build_agent( + agent_def: AgentDefinition, + config: AppConfig, + mcp_server_url: str = "http://localhost:8001/mcp", + exit_stack: Optional[AsyncExitStack] = None, + mcp_headers: Optional[dict[str, str]] = None, + agent_registry=None, + remote_agents: list[dict] | None = None, + call_graph=None, + client_request_id: Optional[str] = None, + context_headers: Optional[dict[str, str]] = None, + current_depth: int = 0, +) -> Any: + """Build an OpenAI Agents SDK Agent from an AgentDefinition. + + Args: + agent_def: Parsed agent definition from .md file. + config: OpenSensa configuration for model resolution. + mcp_server_url: URL of the MCP tool server. + exit_stack: AsyncExitStack for managing MCP connection lifecycle. + mcp_headers: Headers to pass to MCP server (for context injection). + agent_registry: AgentRegistry for resolving sub-agent names (delegation). + remote_agents: Remote agent entries for delegation. + client_request_id: Original client JSON-RPC id, propagated through + all delegation hops for session tracking. + context_headers: Context headers from the client request, cascaded + through delegations so sub-agents and tools share the same context. + current_depth: Current delegation depth (for depth limit enforcement). + + Returns: + An Agent instance ready for Runner.run(). + """ + from agents import Agent + from agents.mcp import MCPServerStreamableHttp + + # Resolve model + model = create_model_from_ref(config, agent_def.model) + + # Build MCP server connection with runtime headers + headers = {} + if mcp_headers: + headers.update(mcp_headers) + + # MCP is only needed for tools — delegation uses native function tools + mcp_servers = [] + if agent_def.tools: + mcp_server = MCPServerStreamableHttp( + params={ + "url": mcp_server_url, + "timeout": 30, # tool calls like document_search can take >5s + **({"headers": headers} if headers else {}), + }, + ) + mcp_servers.append(mcp_server) + + # If we have an exit stack, enter the MCP server context + if exit_stack: + try: + await exit_stack.enter_async_context(mcp_server) + except Exception as exc: + raise RuntimeError( + f"Failed to connect to MCP server at {mcp_server_url}: {exc}. " + "Verify the URL is correct and the server is running." + ) from exc + + # Build tool filter if agent specifies specific tools + tool_filter = None + if agent_def.tools: + allowed = set(agent_def.tools) + + def _tool_filter(tool) -> bool: + """Only allow tools listed in the agent definition.""" + name = getattr(tool, "name", None) or getattr(tool, "function", {}).get("name", "") + return name in allowed + + # Use static tool filter from agents SDK if available + try: + from agents.mcp import create_static_tool_filter + tool_filter = create_static_tool_filter(list(allowed)) + except ImportError: + tool_filter = _tool_filter + + # Build native function tools (non-MCP) + native_tools: list[Any] = [] + + # If agent declares sub_agents, add the delegate tool as a native FunctionTool. + # This calls sub-agents over A2A directly — no MCP timeout issues. + if agent_def.sub_agents: + from opensensa.framework_tools.delegate import build_delegate_tool + + advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host + local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" + + delegate_tool = build_delegate_tool( + agent_def, + agent_registry=agent_registry, + local_base_url=local_base_url, + remote_agents=remote_agents, + call_graph=call_graph, + client_request_id=client_request_id, + context_headers=context_headers, + current_depth=current_depth, + ) + native_tools.append(delegate_tool) + + # Build agent + agent = Agent( + name=agent_def.name, + instructions=agent_def.system_prompt, + model=model, + tools=native_tools, + mcp_servers=mcp_servers, + mcp_config={ + "tool_filter": tool_filter, + } if tool_filter else {}, + ) + + logger.info(f"Built agent: {agent_def.name} (model={agent_def.model}, tools={agent_def.tools}, sub_agents={agent_def.sub_agents})") + return agent diff --git a/src/opensensa/orchestrator/agent_registry.py b/src/opensensa/orchestrator/agent_registry.py new file mode 100644 index 0000000..1238e36 --- /dev/null +++ b/src/opensensa/orchestrator/agent_registry.py @@ -0,0 +1,181 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Filesystem-based agent discovery — scans agents/ directory for .md files with YAML frontmatter. + +Each .md file becomes an agent definition with: + - Frontmatter → agent config (name, description, model, tools, skills, etc.) + - Markdown body → system prompt +""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import frontmatter + +logger = logging.getLogger("opensensa.agents") + + +@dataclass +class AgentSkill: + """An A2A Agent Card skill derived from frontmatter.""" + id: str + name: str + description: str + tags: list[str] = field(default_factory=list) + examples: list[str] = field(default_factory=list) + + +def _parse_context_headers(raw: Any) -> list[str]: + """Normalize context_headers from YAML — accepts a list or a dict (legacy).""" + if isinstance(raw, list): + return [str(h) for h in raw] + if isinstance(raw, dict): + return list(raw.keys()) + return [] + + +@dataclass +class AgentDefinition: + """A parsed agent definition from a .md file.""" + name: str + description: str + model: str + tools: list[str] + sub_agents: list[str] + system_prompt: str + skills: list[AgentSkill] + input_modes: list[str] + output_modes: list[str] + context_headers: list[str] + source_path: Path + + @classmethod + def from_file(cls, path: Path) -> "AgentDefinition": + """Parse a frontmatter .md file into an AgentDefinition.""" + post = frontmatter.load(str(path)) + meta = post.metadata + + # Parse skills + raw_skills = meta.get("skills", []) + skills = [] + for s in raw_skills: + if isinstance(s, dict): + skills.append(AgentSkill( + id=s.get("id", ""), + name=s.get("name", ""), + description=s.get("description", ""), + tags=s.get("tags", []), + examples=s.get("examples", []), + )) + + # Parse sub_agents — list of agent names this agent can delegate to + raw_sub_agents = meta.get("sub_agents", []) + sub_agents = [str(s) for s in raw_sub_agents] if raw_sub_agents else [] + + return cls( + name=meta.get("name", path.stem), + description=meta.get("description", ""), + model=meta.get("model", "${default}"), + tools=meta.get("tools", []), + sub_agents=sub_agents, + system_prompt=post.content.strip(), + skills=skills, + input_modes=meta.get("input_modes", ["text/plain"]), + output_modes=meta.get("output_modes", ["text/plain"]), + context_headers=_parse_context_headers(meta.get("context_headers", [])), + source_path=path, + ) + + +class AgentRegistry: + """Discovers and manages agent definitions from the filesystem. + + Scans the agents directory for .md files, parses them, and provides + lookup by name. Re-scans on each access to pick up file changes + without restart. + """ + + def __init__(self, agents_directory: str | Path): + self._agents_dir = Path(agents_directory).resolve() + self._cache: dict[str, AgentDefinition] = {} + self._last_scan_mtimes: dict[str, float] = {} + + @property + def agents_dir(self) -> Path: + return self._agents_dir + + def scan(self) -> dict[str, AgentDefinition]: + """Scan the agents directory and return all agent definitions. + + Re-reads files that have changed since the last scan. + """ + if not self._agents_dir.exists(): + logger.warning(f"Agents directory does not exist: {self._agents_dir}") + return {} + + current_files: set[str] = set() + + for md_file in sorted(self._agents_dir.glob("*.md")): + file_key = str(md_file) + current_files.add(file_key) + + try: + mtime = md_file.stat().st_mtime + # Skip if unchanged + if file_key in self._last_scan_mtimes and self._last_scan_mtimes[file_key] == mtime: + continue + + agent_def = AgentDefinition.from_file(md_file) + self._cache[agent_def.name] = agent_def + self._last_scan_mtimes[file_key] = mtime + logger.info(f"Loaded agent: {agent_def.name} from {md_file.name}") + + except Exception as e: + logger.error(f"Failed to parse agent file {md_file.name}: {e}") + + # Remove agents whose files were deleted + to_remove = [] + for file_key in list(self._last_scan_mtimes.keys()): + if file_key not in current_files: + to_remove.append(file_key) + for file_key in to_remove: + del self._last_scan_mtimes[file_key] + # Find and remove the agent from cache + for name, defn in list(self._cache.items()): + if str(defn.source_path) == file_key: + del self._cache[name] + logger.info(f"Removed agent: {name} (file deleted)") + break + + return self._cache.copy() + + def get(self, name: str) -> Optional[AgentDefinition]: + """Get an agent definition by name, re-scanning if needed.""" + self.scan() + return self._cache.get(name) + + def list_agents(self) -> list[AgentDefinition]: + """Return all known agent definitions.""" + self.scan() + return list(self._cache.values()) + + def agent_names(self) -> list[str]: + """Return all known agent names.""" + self.scan() + return list(self._cache.keys()) diff --git a/src/opensensa/orchestrator/models.py b/src/opensensa/orchestrator/models.py new file mode 100644 index 0000000..bd7a917 --- /dev/null +++ b/src/opensensa/orchestrator/models.py @@ -0,0 +1,94 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Simplified model layer — OpenAIChatCompletionsModel only. + +Every model goes through AsyncOpenAI(base_url=..., api_key=...) → +OpenAIChatCompletionsModel. Works with OpenAI, Ollama, vLLM, LM Studio, +Together, Groq, Fireworks — anything with an OpenAI-compatible endpoint. +""" + +import logging +import os +import re +from typing import Any + +from openai import AsyncOpenAI + +from opensensa.config import AppConfig, ModelConfig, resolve_model + +logger = logging.getLogger("opensensa.orchestrator") + + +def create_model(model_config: ModelConfig) -> Any: + """Create an OpenAIChatCompletionsModel from a ModelConfig. + + Args: + model_config: Resolved model configuration with base_url, api_key, model_name. + + Returns: + An OpenAIChatCompletionsModel instance ready for use with Agent(). + """ + from agents import OpenAIChatCompletionsModel + + api_key = model_config.api_key or "" + + # Safety: resolve any leftover ${VAR} references at runtime + _env_re = re.compile(r"\$\{(\w+)\}") + match = _env_re.search(api_key) + if match: + var_name = match.group(1) + resolved = os.environ.get(var_name) + if resolved: + api_key = _env_re.sub(resolved, api_key) + else: + raise ValueError( + f"API key references ${{{var_name}}} but it is not set in the environment. " + f"Set it in your .env file or export it in your shell." + ) + + if not api_key or api_key in ("none", "None"): + api_key = "no-key" # Some local servers need a non-empty key + + client = AsyncOpenAI( + base_url=model_config.base_url, + api_key=api_key, + ) + + model = OpenAIChatCompletionsModel( + model=model_config.model_name, + openai_client=client, + ) + + logger.info( + f"Created model: {model_config.model_name} " + f"via {model_config.base_url}" + ) + return model + + +def create_model_from_ref(config: AppConfig, model_ref: str) -> Any: + """Resolve a model reference and create the model. + + Args: + config: Full framework configuration. + model_ref: Model name or "${default}". + + Returns: + An OpenAIChatCompletionsModel instance. + """ + model_config = resolve_model(config, model_ref) + return create_model(model_config) diff --git a/src/opensensa/orchestrator/prompts/__init__.py b/src/opensensa/orchestrator/prompts/__init__.py new file mode 100644 index 0000000..18cd2fc --- /dev/null +++ b/src/opensensa/orchestrator/prompts/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Default and template prompts.""" diff --git a/src/opensensa/orchestrator/prompts/default.txt b/src/opensensa/orchestrator/prompts/default.txt new file mode 100644 index 0000000..a191ab5 --- /dev/null +++ b/src/opensensa/orchestrator/prompts/default.txt @@ -0,0 +1 @@ +You are a helpful AI assistant. Answer the user's questions clearly and concisely. If you have access to tools, use them when they would help provide a better answer. diff --git a/src/opensensa/orchestrator/server.py b/src/opensensa/orchestrator/server.py new file mode 100644 index 0000000..283cba2 --- /dev/null +++ b/src/opensensa/orchestrator/server.py @@ -0,0 +1,209 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""A2A + MCP orchestrator server. + +Serves per-agent A2A endpoints — each agent gets its own +``A2AFastAPIApplication`` sub-app mounted at ``/agents/{name}/``. + +Per the A2A spec, **one agent = one URL = one Agent Card**. + +Layout: + GET /agents/{name}/.well-known/agent-card.json — Agent Card + POST /agents/{name}/ — JSON-RPC endpoint + GET /health — operational + GET /agents — list all agents +""" + +import logging +from typing import Any + +from fastapi import FastAPI +from starlette.requests import Request + +from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication +from a2a.server.apps.jsonrpc.jsonrpc_app import CallContextBuilder +from a2a.server.context import ServerCallContext +from a2a.server.events.in_memory_queue_manager import InMemoryQueueManager +from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler + +from opensensa.a2a.agent_card import build_agent_card +from opensensa.a2a.executor import FrameworkAgentExecutor +from opensensa.a2a.task_store import InMemoryTaskStore +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentRegistry + +logger = logging.getLogger("opensensa.orchestrator") + +# Headers to propagate from inbound HTTP requests into ServerCallContext.state +_PROPAGATED_HEADERS = ("x-a2a-depth", "x-a2a-client-request-id") + + +class _HeaderCallContextBuilder(CallContextBuilder): + """Extracts selected HTTP headers into ``ServerCallContext.state``. + + This lets the executor read headers like ``X-A2A-Depth`` and + ``X-A2A-Client-Request-Id`` that were sent by the ``delegate`` + tool on the calling agent's side. + + On the **first hop** (client → agent), ``X-A2A-Client-Request-Id`` + is not yet present as an HTTP header — the client's id is only in + the JSON-RPC body. In that case we seed it from the cached request + body (which the a2a-sdk has already read before calling ``build()``). + """ + + def build(self, request: Request) -> ServerCallContext: + state: dict[str, Any] = {} + for hdr in _PROPAGATED_HEADERS: + value = request.headers.get(hdr) + if value is not None: + state[hdr] = value + + # Seed client request id from JSON-RPC body on the first hop. + # By the time build() is called the a2a-sdk has already done + # ``await request.body()`` which caches the bytes on the Request. + if "x-a2a-client-request-id" not in state: + try: + import json as _json + body_bytes = getattr(request, "_body", None) + if body_bytes: + data = _json.loads(body_bytes) + jsonrpc_id = data.get("id") + if jsonrpc_id is not None: + state["x-a2a-client-request-id"] = str(jsonrpc_id) + except Exception: + pass + + return ServerCallContext(state=state) + + +def create_orchestrator_app( + config: AppConfig, + agent_registry: AgentRegistry, + mcp_server_url: str | None = None, + enable_web: bool = True, +) -> FastAPI: + """Create the FastAPI application with per-agent A2A sub-apps. + + Each agent defined in the registry gets its own: + - ``A2AFastAPIApplication`` mounted at ``/agents/{name}/`` + - ``AgentCard`` with ``url`` pointing to the sub-app + - ``FrameworkAgentExecutor`` (hardcoded to that agent) + - ``DefaultRequestHandler`` + ``InMemoryTaskStore`` + ``InMemoryQueueManager`` + + Args: + config: Framework configuration. + agent_registry: Registry of local agent definitions. + mcp_server_url: URL of the MCP tool server. Defaults to config-derived URL. + + Returns: + FastAPI app ready to serve. + """ + app = FastAPI(title="Agent Server", version="0.1.0") + + host = config.server.host + port = config.server.orchestrator_port + # Use localhost for the Agent Card URLs so local clients can reach them. + # For 0.0.0.0 we advertise localhost; for explicit IPs we use that. + advertise_host = "localhost" if host == "0.0.0.0" else host + base_url = f"http://{advertise_host}:{port}" + + if mcp_server_url is None: + mcp_server_url = f"http://localhost:{config.server.mcp_port}/mcp" + + agents = agent_registry.list_agents() + + # Shared context builder that extracts HTTP headers into ServerCallContext + context_builder = _HeaderCallContextBuilder() + + # Mount one A2AFastAPIApplication per agent + for agent_def in agents: + agent_card = build_agent_card(agent_def, base_url) + executor = FrameworkAgentExecutor( + agent_name=agent_def.name, + agent_registry=agent_registry, + config=config, + mcp_server_url=mcp_server_url, + ) + handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), + queue_manager=InMemoryQueueManager(), + ) + a2a_sub = A2AFastAPIApplication( + agent_card=agent_card, + http_handler=handler, + context_builder=context_builder, + ) + sub_app = a2a_sub.build() + app.mount(f"/agents/{agent_def.name}", sub_app) + logger.info(f"Mounted A2A sub-app for agent '{agent_def.name}' at /agents/{agent_def.name}/") + + logger.info( + f"A2A server configured with {len(agents)} agent(s): " + f"{[a.name for a in agents]}" + ) + + # --- Web frontend --- + if enable_web: + try: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + from opensensa.web.routes import create_web_router + + web_router = create_web_router( + config=config, + registry=agent_registry, + mcp_server_url=mcp_server_url, + ) + app.include_router(web_router) + logger.info(f"Web UI enabled at http://{advertise_host}:{port}/web") + except Exception: + logger.warning("Failed to enable web UI", exc_info=True) + + # --- Convenience endpoints (non-A2A) --- + + @app.get("/health") + async def health(): + return {"status": "ok", "agents": agent_registry.agent_names()} + + @app.get("/agents") + async def list_agents_endpoint(): + """List all local agents with their metadata and A2A URLs.""" + current_agents = agent_registry.list_agents() + return [ + { + "name": a.name, + "description": a.description, + "url": f"{base_url}/agents/{a.name}", + "model": a.model, + "tools": a.tools, + "skills": [ + {"id": s.id, "name": s.name, "description": s.description} + for s in a.skills + ], + } + for a in current_agents + ] + + return app diff --git a/src/opensensa/orchestrator/tracing.py b/src/opensensa/orchestrator/tracing.py new file mode 100644 index 0000000..5ff7173 --- /dev/null +++ b/src/opensensa/orchestrator/tracing.py @@ -0,0 +1,134 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tracing — structured trace context for agent execution. + +Provides AgentTraceContext and AgentSpan for hierarchical tracing of +agent runs, tool calls, and delegations. All output goes to structured +JSON logs (no database). +""" + +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +from uuid import uuid4 + +logger = logging.getLogger("opensensa.tracing") + + +@dataclass +class AgentSpan: + """A single span in an agent execution trace.""" + span_id: str = field(default_factory=lambda: str(uuid4())) + parent_span_id: Optional[str] = None + trace_id: str = "" + agent_name: str = "" + event_type: str = "agent_run" # agent_run, tool_call, delegation, etc. + start_time_ms: int = 0 + end_time_ms: int = 0 + duration_ms: int = 0 + status: str = "started" # started, completed, error + metadata: dict[str, Any] = field(default_factory=dict) + events: list[dict[str, Any]] = field(default_factory=list) + usage: dict[str, int] = field(default_factory=dict) # input_tokens, output_tokens + + def complete(self, status: str = "completed", **extra_metadata): + self.end_time_ms = int(time.time() * 1000) + self.duration_ms = self.end_time_ms - self.start_time_ms + self.status = status + self.metadata.update(extra_metadata) + self._emit_log() + + def add_event(self, event_type: str, data: Any = None): + self.events.append({ + "type": event_type, + "timestamp_ms": int(time.time() * 1000), + "data": data, + }) + + def _emit_log(self): + """Emit structured JSON log for this span.""" + entry = { + "event": "trace_span", + "trace_id": self.trace_id, + "span_id": self.span_id, + "parent_span_id": self.parent_span_id, + "agent_name": self.agent_name, + "event_type": self.event_type, + "status": self.status, + "start_time_ms": self.start_time_ms, + "end_time_ms": self.end_time_ms, + "duration_ms": self.duration_ms, + } + if self.usage: + entry["usage"] = self.usage + if self.metadata: + entry["metadata"] = self.metadata + + record = logger.makeRecord( + name="opensensa.tracing", + level=logging.INFO, + fn="", + lno=0, + msg=f"Span: {self.agent_name}/{self.event_type} ({self.status}, {self.duration_ms}ms)", + args=(), + exc_info=None, + ) + record.structured_data = entry # type: ignore[attr-defined] + logger.handle(record) + + +class AgentTraceContext: + """Manages a tree of spans for a single request trace.""" + + def __init__(self, trace_id: Optional[str] = None): + self.trace_id = trace_id or str(uuid4()) + self._spans: list[AgentSpan] = [] + self._active_span: Optional[AgentSpan] = None + + def start_span( + self, + agent_name: str, + event_type: str = "agent_run", + parent_span_id: Optional[str] = None, + **metadata, + ) -> AgentSpan: + """Create and start a new span.""" + if parent_span_id is None and self._active_span: + parent_span_id = self._active_span.span_id + + span = AgentSpan( + trace_id=self.trace_id, + agent_name=agent_name, + event_type=event_type, + parent_span_id=parent_span_id, + start_time_ms=int(time.time() * 1000), + metadata=metadata, + ) + self._spans.append(span) + self._active_span = span + return span + + @property + def active_span(self) -> Optional[AgentSpan]: + return self._active_span + + @property + def spans(self) -> list[AgentSpan]: + return self._spans.copy() diff --git a/src/opensensa/orchestrator/validators.py b/src/opensensa/orchestrator/validators.py new file mode 100644 index 0000000..6e89871 --- /dev/null +++ b/src/opensensa/orchestrator/validators.py @@ -0,0 +1,48 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Request validation utilities.""" + +from typing import Any, Optional + + +class FrameworkError(Exception): + """Base exception for framework errors.""" + pass + + +class ValidationError(FrameworkError): + """Request validation error.""" + def __init__(self, message: str, field: Optional[str] = None): + self.field = field + super().__init__(message) + + +class AgentConfigurationError(FrameworkError): + """Agent configuration or setup error.""" + pass + + +class ModelNotFoundError(FrameworkError): + """Model reference could not be resolved.""" + pass + + +def validate_agent_name(name: str) -> str: + """Validate and normalize an agent name.""" + if not name or not name.strip(): + raise ValidationError("Agent name cannot be empty", field="name") + return name.strip() diff --git a/src/opensensa/tools/__init__.py b/src/opensensa/tools/__init__.py new file mode 100644 index 0000000..3329b92 --- /dev/null +++ b/src/opensensa/tools/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Bundled example tools — copied to user project on `opensensa init`.""" diff --git a/src/opensensa/tools/add_numbers.py b/src/opensensa/tools/add_numbers.py new file mode 100644 index 0000000..92d77dc --- /dev/null +++ b/src/opensensa/tools/add_numbers.py @@ -0,0 +1,30 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Simple addition tool for testing MCP functionality.""" + + +def register(mcp): + """Register the add_numbers tool with the given MCP instance.""" + + @mcp.tool( + title="Add Numbers", + description="Add two numbers together. A simple test tool for validating MCP functionality.", + tags=["math", "arithmetic", "test"], + ) + async def add_numbers(a: int, b: int) -> int: + """Add two numbers and return the result.""" + return a + b diff --git a/src/opensensa/tools/csv_formatter.py b/src/opensensa/tools/csv_formatter.py new file mode 100644 index 0000000..97b2579 --- /dev/null +++ b/src/opensensa/tools/csv_formatter.py @@ -0,0 +1,97 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""CSV formatting tool — converts structured data to pipe-delimited CSV format.""" + +import logging +from typing import Any, Dict, List, Optional, Union + +logger = logging.getLogger("opensensa.tools") + + +def _format_csv_value(value: Any) -> str: + """Format a single value for CSV output (pipe delimiter, RFC 4180 quoting).""" + if value is None: + return "" + str_value = str(value) + needs_quotes = ( + "|" in str_value + or "," in str_value + or '"' in str_value + or "\n" in str_value + or "\r" in str_value + or str_value != str_value.strip() + or (len(str_value) > 0 and str_value[0] in "=+-@") + ) + if needs_quotes: + escaped_value = str_value.replace('"', '""') + return f'"{escaped_value}"' + return str_value + + +def register(mcp): + """Register the csv_formatter tool with the given MCP instance.""" + + @mcp.tool( + title="Format Data as CSV", + description=( + "Convert structured data into pipe-delimited CSV format. Use when the user " + "requests tabular data, spreadsheet output, or data export." + ), + tags=["formatting", "csv", "data"], + ) + def format_as_csv( + data: Union[List[Dict[str, Any]], None] = None, + columns: Union[List[str], None] = None, + table_name: str = "", + rows: Union[List[Dict[str, Any]], None] = None, + records: Union[List[Dict[str, Any]], None] = None, + ) -> str: + """Convert list of dictionaries to pipe-delimited CSV format. + + Args: + data: List of row dictionaries. + columns: Optional column order. If None, uses keys from first row. + table_name: Optional title added as a comment line. + rows: Alias for data. + records: Alias for data. + """ + if data is None: + data = rows if rows is not None else records + if data is None: + return "```csv\n# Error: missing required field 'data'\n```" + if not data: + return "```csv\n# No data to display\n```" + if not isinstance(data, list): + return "```csv\n# Error: data must be a list of dictionaries\n```" + + if columns is None: + if not isinstance(data[0], dict): + return "```csv\n# Error: data items must be dictionaries\n```" + columns = list(data[0].keys()) + if not columns: + return "```csv\n# No columns to display\n```" + + csv_lines: list[str] = [] + if table_name: + csv_lines.append(f"# {table_name}") + csv_lines.append("|".join(_format_csv_value(col) for col in columns)) + for row in data: + if not isinstance(row, dict): + continue + csv_lines.append("|".join(_format_csv_value(row.get(col)) for col in columns)) + + return f"```csv\n{chr(10).join(csv_lines)}\n```" diff --git a/src/opensensa/tools/generate_visualization.py b/src/opensensa/tools/generate_visualization.py new file mode 100644 index 0000000..99e2600 --- /dev/null +++ b/src/opensensa/tools/generate_visualization.py @@ -0,0 +1,150 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Chart.js visualization specification builder.""" + +import json +import logging +from typing import Any, Dict, List, Literal, Optional + +logger = logging.getLogger("opensensa.tools") + +DEFAULT_COLORS = [ + "rgba(54, 162, 235, 0.8)", + "rgba(255, 99, 132, 0.8)", + "rgba(75, 192, 192, 0.8)", + "rgba(153, 102, 255, 0.8)", + "rgba(255, 206, 86, 0.8)", + "rgba(255, 159, 64, 0.8)", +] + +VALID_CHART_TYPES = {"line", "bar", "pie", "doughnut", "radar", "scatter", "bubble", "polarArea"} + + +def register(mcp): + """Register the generate_visualization tool with the given MCP instance.""" + + @mcp.tool( + title="Visualize Charts", + description=( + "Create interactive Chart.js visualizations (line, bar, pie, doughnut, radar, " + "scatter, bubble, polarArea) from data for rendering in the frontend." + ), + tags=["visualization", "charts", "data"], + ) + def generate_visualization( + chart_type: Optional[Literal["line", "bar", "pie", "doughnut", "radar", "scatter", "bubble", "polarArea"]] = "bar", + labels: Optional[List[str]] = None, + datasets: Optional[List[Dict[str, Any]]] = None, + title: Optional[str] = None, + x_axis_title: Optional[str] = None, + y_axis_title: Optional[str] = None, + stacked: bool = False, + show_legend: bool = True, + legend_position: Literal["top", "bottom", "left", "right"] = "bottom", + ) -> str: + """Create a Chart.js spec from data. Returns a ```chart code fence.""" + + # --- Defensive validation: recover from missing / invalid fields --- + errors: list[str] = [] + if not labels or not isinstance(labels, list): + errors.append("'labels' is required and must be a non-empty list of strings") + if not datasets or not isinstance(datasets, list): + errors.append("'datasets' is required and must be a non-empty list of dataset objects (each with 'label' and 'data' keys)") + if errors: + error_msg = "generate_visualization validation failed: " + "; ".join(errors) + logger.error(error_msg) + return f'{{"error": "{error_msg}"}}' + + # Normalise chart_type — accept None / misspellings gracefully + if not chart_type or chart_type not in VALID_CHART_TYPES: + logger.warning(f"Invalid chart_type '{chart_type}', falling back to 'bar'") + chart_type = "bar" + + try: + chart_spec: dict[str, Any] = { + "type": chart_type, + "data": {"labels": labels, "datasets": []}, + "options": {"responsive": True, "maintainAspectRatio": True, "plugins": {}}, + } + + for idx, dataset in enumerate(datasets): + ds: dict[str, Any] = { + "label": dataset.get("label", f"Dataset {idx + 1}"), + "data": dataset.get("data", []), + } + if "backgroundColor" not in dataset: + if chart_type in ("pie", "doughnut", "polarArea"): + ds["backgroundColor"] = DEFAULT_COLORS[: len(labels)] + else: + ds["backgroundColor"] = DEFAULT_COLORS[idx % len(DEFAULT_COLORS)] + else: + ds["backgroundColor"] = dataset["backgroundColor"] + + if "borderColor" not in dataset and chart_type in ("line", "radar"): + color = DEFAULT_COLORS[idx % len(DEFAULT_COLORS)] + ds["borderColor"] = color.replace("0.8)", "1)") + elif "borderColor" in dataset: + ds["borderColor"] = dataset["borderColor"] + + if chart_type == "line": + ds["tension"] = dataset.get("tension", 0.4) + ds["fill"] = dataset.get("fill", False) + + for key in ("borderWidth", "pointRadius", "pointHoverRadius", "fill", "tension"): + if key in dataset: + ds[key] = dataset[key] + + chart_spec["data"]["datasets"].append(ds) + + if title: + chart_spec["options"]["plugins"]["title"] = { + "display": True, + "text": title, + "font": {"size": 16}, + } + + chart_spec["options"]["plugins"]["legend"] = { + "display": show_legend, + "position": legend_position, + } + + if chart_type not in ("pie", "doughnut", "radar", "polarArea"): + scales: dict[str, Any] = {} + y_cfg: dict[str, Any] = {"beginAtZero": True} + if y_axis_title: + y_cfg["title"] = {"display": True, "text": y_axis_title} + if stacked: + y_cfg["stacked"] = True + scales["y"] = y_cfg + + x_cfg: dict[str, Any] = {} + if x_axis_title: + x_cfg["title"] = {"display": True, "text": x_axis_title} + if stacked: + x_cfg["stacked"] = True + if x_cfg: + scales["x"] = x_cfg + chart_spec["options"]["scales"] = scales + + json_str = json.dumps(chart_spec, indent=2) + logger.info(f"Generated {chart_type} chart with {len(datasets)} dataset(s)") + return f"```chart\n{json_str}\n```" + + except Exception as e: + error_msg = f"Failed to create chart: {e}" + logger.error(error_msg) + return f'```chart\n{{"error": "{error_msg}"}}\n```' diff --git a/src/opensensa/utils/__init__.py b/src/opensensa/utils/__init__.py new file mode 100644 index 0000000..ce67ae3 --- /dev/null +++ b/src/opensensa/utils/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Utility modules — logging, helpers.""" diff --git a/src/opensensa/utils/logging.py b/src/opensensa/utils/logging.py new file mode 100644 index 0000000..df379c0 --- /dev/null +++ b/src/opensensa/utils/logging.py @@ -0,0 +1,259 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Structured JSON logging and LoggedMCP wrapper. + +Provides: +- setup_logging(): Configures structured JSON logging for the framework +- LoggedMCP: Wrapper around FastMCP that logs every tool call as structured JSON +""" + +import asyncio +import functools +import json +import logging +import sys +import time +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +logger = logging.getLogger("opensensa") + + +# --------------------------------------------------------------------------- +# Structured JSON formatter +# --------------------------------------------------------------------------- + +class StructuredJSONFormatter(logging.Formatter): + """Formats log records as single-line JSON objects.""" + + def format(self, record: logging.LogRecord) -> str: + log_entry: dict[str, Any] = { + "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + # Merge any extra structured data attached to the record + if hasattr(record, "structured_data"): + log_entry.update(record.structured_data) + if record.exc_info and record.exc_info[1]: + log_entry["exception"] = self.formatException(record.exc_info) + return json.dumps(log_entry, default=str) + + +def setup_logging( + level: str = "info", + log_file: Optional[str] = None, + *, + console_output: bool = True, +) -> None: + """Configure structured logging for OpenSensa. + + Args: + level: Log level string (debug, info, warning, error). + log_file: Optional path to a .jsonl file for persistent logs. + console_output: If False, skip the stderr handler (useful in chat mode + where Rich owns the terminal). + """ + log_level = getattr(logging, level.upper(), logging.INFO) + root = logging.getLogger("opensensa") + root.setLevel(log_level) + + # Remove existing handlers to avoid duplicates on reload + root.handlers.clear() + + # Console handler — only when not in interactive chat mode + if console_output: + console = logging.StreamHandler(sys.stderr) + console.setLevel(log_level) + console.setFormatter(StructuredJSONFormatter()) + root.addHandler(console) + + # Optional file handler + if log_file: + from pathlib import Path + + Path(log_file).parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_file, mode="a", encoding="utf-8") + fh.setLevel(log_level) + fh.setFormatter(StructuredJSONFormatter()) + root.addHandler(fh) + + # Don't propagate to root logger + root.propagate = False + + +# --------------------------------------------------------------------------- +# Tool call structured log helper +# --------------------------------------------------------------------------- + +def _log_tool_call( + tool_name: str, + parameters: Optional[dict], + response: Any, + status: str, + start_epoch_ms: int, + end_epoch_ms: int, + error: Optional[str] = None, +) -> None: + """Emit a structured log entry for a tool invocation.""" + entry = { + "event": "tool_call", + "tool_name": tool_name, + "status": status, + "duration_ms": end_epoch_ms - start_epoch_ms, + "start_epoch_ms": start_epoch_ms, + "end_epoch_ms": end_epoch_ms, + } + if parameters: + # Truncate very large parameter values for logging + entry["parameters"] = _truncate(parameters) + if error: + entry["error"] = error + if status == "success" and response is not None: + entry["response_preview"] = _truncate(response, max_size=2000) + + tool_logger = logging.getLogger("opensensa.tools") + record = tool_logger.makeRecord( + name="opensensa.tools", + level=logging.INFO if status == "success" else logging.ERROR, + fn="", + lno=0, + msg=f"Tool call: {tool_name} ({status}, {end_epoch_ms - start_epoch_ms}ms)", + args=(), + exc_info=None, + ) + record.structured_data = entry # type: ignore[attr-defined] + tool_logger.handle(record) + + +def _truncate(data: Any, max_size: int = 100_000) -> Any: + """Truncate data to prevent log explosion.""" + if data is None: + return None + try: + json_str = json.dumps(data, default=str) + if len(json_str) > max_size: + return {"_truncated": True, "_size": len(json_str), "_preview": json_str[:1000]} + return data + except Exception: + return {"_error": "Could not serialize"} + + +# --------------------------------------------------------------------------- +# LoggedMCP wrapper +# --------------------------------------------------------------------------- + +def _create_logged_wrapper(func: Callable, tool_name: str) -> Callable: + """Create a wrapper that emits structured JSON logs for each tool call.""" + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + start = int(time.time() * 1000) + try: + result = await func(*args, **kwargs) + end = int(time.time() * 1000) + _log_tool_call(tool_name, kwargs or None, result, "success", start, end) + return result + except Exception as e: + end = int(time.time() * 1000) + _log_tool_call(tool_name, kwargs or None, None, "error", start, end, error=str(e)) + raise + + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + start = int(time.time() * 1000) + try: + result = func(*args, **kwargs) + end = int(time.time() * 1000) + _log_tool_call(tool_name, kwargs or None, result, "success", start, end) + return result + except Exception as e: + end = int(time.time() * 1000) + _log_tool_call(tool_name, kwargs or None, None, "error", start, end, error=str(e)) + raise + + if asyncio.iscoroutinefunction(func): + return async_wrapper + return sync_wrapper + + +class LoggedMCP: + """Wrapper around FastMCP that automatically logs all tool calls as structured JSON. + + Usage: + from mcp.server.fastmcp import FastMCP + from opensensa.utils.logging import LoggedMCP + + _mcp = FastMCP("ToolServer", host="0.0.0.0", port=8001) + mcp = LoggedMCP(_mcp) + + @mcp.tool(title="My Tool", description="...") + def my_tool(query: str) -> dict: + return {"result": "..."} + """ + + def __init__(self, mcp_instance: Any, enable_logging: bool = True): + self._mcp = mcp_instance + self._enable_logging = enable_logging + self._registered_tools: dict[str, dict[str, Any]] = {} + + def tool(self, *args: Any, **kwargs: Any) -> Callable: + """Decorator that registers a tool with automatic structured logging.""" + # Handle tags shorthand → merge into meta + tags = kwargs.pop("tags", None) + if tags is not None: + meta = kwargs.get("meta") or {} + meta["tags"] = [str(t) for t in tags] + kwargs["meta"] = meta + + tool_title = kwargs.get("title", None) + + def decorator(func: Callable) -> Callable: + tool_name = tool_title or func.__name__ + + if self._enable_logging: + logged_func = _create_logged_wrapper(func, tool_name) + else: + logged_func = func + + decorated = self._mcp.tool(*args, **kwargs)(logged_func) + + meta = kwargs.get("meta") or {} + self._registered_tools[tool_name] = { + "function": func.__name__, + "title": tool_title, + "description": kwargs.get("description", ""), + "logged": self._enable_logging, + "tags": meta.get("tags", []), + } + return decorated + + return decorator + + def run(self, *args: Any, **kwargs: Any) -> Any: + """Proxy to underlying MCP run method.""" + return self._mcp.run(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + """Proxy all other attributes to the underlying MCP instance.""" + return getattr(self._mcp, name) + + @property + def registered_tools(self) -> dict[str, dict[str, Any]]: + """Return dict of all registered tools and their metadata.""" + return self._registered_tools.copy() diff --git a/src/opensensa/web/__init__.py b/src/opensensa/web/__init__.py new file mode 100644 index 0000000..be3db5c --- /dev/null +++ b/src/opensensa/web/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""OpenSensa Web UI — embedded chat frontend served from the FastAPI orchestrator.""" diff --git a/src/opensensa/web/call_graph.py b/src/opensensa/web/call_graph.py new file mode 100644 index 0000000..cb98625 --- /dev/null +++ b/src/opensensa/web/call_graph.py @@ -0,0 +1,404 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""SSE-emitting call graph adapter for the web frontend. + +Wraps the terminal ``CallGraph`` and pushes JSON-serializable events to an +``asyncio.Queue`` that the SSE endpoint drains. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Optional + + +# --------------------------------------------------------------------------- +# Lightweight node (mirrors interactive/call_graph._CallNode) +# --------------------------------------------------------------------------- + +@dataclass +class CallNode: + """A single node in the web call graph.""" + + id: str + label: str + kind: str # "tool", "delegation", "llm", "agent" + status: str = "running" # running | completed | failed + start_time: float = field(default_factory=time.monotonic) + end_time: Optional[float] = None + duration_ms: Optional[int] = None + result_preview: Optional[str] = None + children: list["CallNode"] = field(default_factory=list) + + def finish( + self, + *, + duration_ms: int | None = None, + result_preview: str | None = None, + status: str = "completed", + ) -> None: + self.end_time = time.monotonic() + self.duration_ms = duration_ms or int((self.end_time - self.start_time) * 1000) + self.result_preview = result_preview + self.status = status + + def to_dict(self) -> dict[str, Any]: + """Serialize this node (and its children) to a plain dict.""" + return { + "id": self.id, + "label": self.label, + "kind": self.kind, + "status": self.status, + "duration_ms": self.duration_ms, + "result_preview": self.result_preview, + "children": [c.to_dict() for c in self.children], + } + + +# --------------------------------------------------------------------------- +# WebCallGraph — pushes SSE events +# --------------------------------------------------------------------------- + +class WebCallGraph: + """Call graph that emits SSE events instead of rendering Rich trees. + + Each event method pushes a JSON message to an asyncio Queue. + The SSE endpoint reads from this queue and streams to the browser. + """ + + def __init__(self, agent_name: str) -> None: + self.agent_name = agent_name + self._root_nodes: list[CallNode] = [] + self._active_stack: list[CallNode] = [] + self._node_map: dict[str, CallNode] = {} + self._key_seq: int = 0 + self._turn_start: float = time.monotonic() + self._total_tokens: int = 0 + self._llm_calls: int = 0 + self._tool_calls: int = 0 + self._delegations: int = 0 + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._finished = False + + # -- Key generation ------------------------------------------------------ + + def _next_key(self, prefix: str) -> str: + self._key_seq += 1 + return f"{prefix}:{self._key_seq}" + + def _find_running(self, prefix: str) -> CallNode | None: + for key in reversed(list(self._node_map)): + if key.startswith(prefix + ":"): + node = self._node_map[key] + if node.status == "running": + return node + return None + + # -- SSE event helpers --------------------------------------------------- + + def _emit(self, event_type: str, data: dict[str, Any] | None = None) -> None: + """Push an SSE event onto the queue.""" + payload: dict[str, Any] = {"event": event_type} + if data: + payload.update(data) + # Include the full tree snapshot so the client can re-render + payload["tree"] = [n.to_dict() for n in self._root_nodes] + payload["stats"] = { + "tool_calls": self._tool_calls, + "delegations": self._delegations, + "llm_calls": self._llm_calls, + "total_tokens": self._total_tokens, + "elapsed_ms": int((time.monotonic() - self._turn_start) * 1000), + } + try: + self._queue.put_nowait(payload) + except asyncio.QueueFull: + pass # drop event if queue is full (shouldn't happen) + + def _add_node(self, node: CallNode, key: str) -> None: + self._node_map[key] = node + if self._active_stack: + self._active_stack[-1].children.append(node) + else: + self._root_nodes.append(node) + + # -- Event methods (same interface as interactive CallGraph) ------------- + + def start_llm(self, agent_name: str | None = None) -> None: + label = f"LLM → {agent_name}" if agent_name else "LLM call" + key = self._next_key("llm") + node = CallNode(id=key, label=label, kind="llm") + self._add_node(node, key) + self._llm_calls += 1 + self._emit("llm_start", {"node_id": key, "label": label}) + + def end_llm( + self, + agent_name: str | None = None, + duration_ms: int = 0, + tokens: int = 0, + ) -> None: + node = self._find_running("llm") + if node: + result = f"{tokens} tokens" if tokens else None + node.finish(duration_ms=duration_ms, result_preview=result) + self._total_tokens += tokens + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + self._emit("llm_end", { + "node_id": node.id, + "duration_ms": duration_ms, + "tokens": tokens, + }) + + def start_tool(self, tool_name: str) -> None: + key = self._next_key("tool") + if tool_name == "delegate": + node = CallNode(id=key, label="delegating…", kind="delegation") + self._add_node(node, key) + self._delegations += 1 + else: + node = CallNode(id=key, label=tool_name, kind="tool") + self._add_node(node, key) + self._tool_calls += 1 + self._emit("tool_start", {"node_id": key, "tool": tool_name}) + + def end_tool( + self, + tool_name: str, + duration_ms: int = 0, + result: str | None = None, + ) -> None: + if tool_name == "delegate": + node = self._find_running("tool") + if node and node.kind != "delegation": + node = None + else: + node = self._find_running("tool") + if node and node.label != tool_name: + node = None + if not node: + return + + if tool_name == "delegate" and result: + self._finish_delegate_node(node, result, duration_ms) + else: + preview = result[:80].replace("\n", " ") if result else None + node.finish(duration_ms=duration_ms, result_preview=preview) + + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + + self._emit("tool_end", { + "node_id": node.id, + "tool": tool_name, + "duration_ms": duration_ms, + "result_preview": node.result_preview, + }) + + def start_delegation(self, from_agent: str, to_agent: str, *, message: str = "") -> None: + key = self._next_key("delegation") + label = f"{from_agent} → {to_agent}" + node = CallNode(id=key, label=label, kind="delegation") + self._add_node(node, key) + self._active_stack.append(node) + self._delegations += 1 + self._emit("delegation_start", { + "node_id": key, + "from_agent": from_agent, + "to_agent": to_agent, + "message": message, + }) + + def end_delegation(self, to_agent: str, duration_ms: int = 0, *, response: str = "") -> None: + node = self._find_running("delegation") + if node: + node.finish(duration_ms=duration_ms) + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + self._emit("delegation_end", { + "node_id": node.id, + "to_agent": to_agent, + "duration_ms": duration_ms, + "response": response, + }) + + def start_agent(self, agent_name: str) -> None: + if agent_name != self.agent_name: + key = self._next_key("agent") + node = CallNode(id=key, label=agent_name, kind="agent") + self._add_node(node, key) + self._emit("agent_start", {"node_id": key, "agent": agent_name}) + + def end_agent(self, agent_name: str, duration_ms: int = 0) -> None: + node = self._find_running("agent") + if node: + node.finish(duration_ms=duration_ms) + if self._active_stack and self._active_stack[-1] is node: + self._active_stack.pop() + self._emit("agent_end", { + "node_id": node.id, + "agent": agent_name, + "duration_ms": duration_ms, + }) + + def update_delegation_target(self, agent_name: str) -> None: + node = self._find_running("tool") + if node and node.kind == "delegation": + node.label = f"delegate → {agent_name}" + self._emit("delegation_update", {"node_id": node.id, "agent": agent_name}) + + def add_delegation_sub_event( + self, + event_type: str, + tool_name: str, + *, + agent_name: str | None = None, + tools_used: list[dict] | None = None, + ) -> None: + node = self._find_running("tool") + if not node or node.kind != "delegation": + return + + if event_type == "tool_start": + key = self._next_key("sub_tool") + if tool_name == "delegate" and agent_name: + label = f"delegate → {agent_name}" + elif tool_name == "delegate": + label = "delegating…" + else: + label = tool_name + child = CallNode( + id=key, + label=label, + kind=("delegation" if tool_name == "delegate" else "tool"), + ) + node.children.append(child) + elif event_type == "tool_end": + for child in reversed(node.children): + if child.status != "running": + continue + if tool_name == "delegate" and child.kind == "delegation": + if agent_name: + child.label = f"delegate → {agent_name}" + child.finish() + break + elif child.label == tool_name and child.kind == "tool": + child.finish() + break + + self._emit("delegation_sub_event", { + "parent_id": node.id, + "sub_event": event_type, + "tool": tool_name, + }) + + # -- Delegate result parsing --------------------------------------------- + + def _finish_delegate_node( + self, node: CallNode, result_json: str, duration_ms: int + ) -> None: + try: + data = json.loads(result_json) + except (ValueError, TypeError): + node.finish(duration_ms=duration_ms, result_preview=result_json[:80]) + return + + agent_name = data.get("agent", "") + status = data.get("status", "unknown") + + if agent_name: + node.label = f"delegate → {agent_name}" + node.kind = "delegation" + + tools_used = data.get("tools_used", []) + if tools_used: + node.children.clear() + self._add_tool_children(node, tools_used) + + if status == "success": + node.finish(duration_ms=duration_ms, result_preview="completed") + elif status == "error": + error_msg = data.get("error", "unknown error")[:60] + node.finish(duration_ms=duration_ms, result_preview=error_msg, status="failed") + else: + node.finish(duration_ms=duration_ms) + + def _add_tool_children(self, parent: CallNode, tools_used: list[dict]) -> None: + for tool_info in tools_used: + tname = tool_info.get("name", "unknown") + agent = tool_info.get("agent") + nested = tool_info.get("tools_used", []) + key = self._next_key("sub_tool") + + if tname == "delegate" and agent: + child = CallNode( + id=key, + label=f"delegate → {agent}", + kind="delegation", + status="completed", + ) + child.end_time = child.start_time + self._add_tool_children(child, nested) + else: + child = CallNode( + id=key, label=tname, kind="tool", status="completed", + ) + child.end_time = child.start_time + + parent.children.append(child) + + # -- Stats --------------------------------------------------------------- + + @property + def elapsed_ms(self) -> int: + return int((time.monotonic() - self._turn_start) * 1000) + + @property + def is_empty(self) -> bool: + return len(self._root_nodes) == 0 + + # -- SSE stream ---------------------------------------------------------- + + def finish(self, response: str | None = None) -> None: + """Signal that the agent turn is complete.""" + self._emit("turn_complete", { + "response": response, + }) + self._finished = True + + def error(self, message: str) -> None: + """Signal that the agent turn failed.""" + self._emit("turn_error", {"error": message}) + self._finished = True + + async def events(self) -> AsyncGenerator[str, None]: + """Yield SSE-formatted strings. Blocks until events arrive or turn finishes.""" + while True: + try: + payload = await asyncio.wait_for(self._queue.get(), timeout=0.5) + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + if payload.get("event") in ("turn_complete", "turn_error"): + return + except asyncio.TimeoutError: + # Send keepalive to prevent connection timeout + yield ": keepalive\n\n" + if self._finished: + return diff --git a/src/opensensa/web/chat_manager.py b/src/opensensa/web/chat_manager.py new file mode 100644 index 0000000..c250825 --- /dev/null +++ b/src/opensensa/web/chat_manager.py @@ -0,0 +1,321 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Web chat session manager — manages concurrent browser chat sessions. + +Each session holds conversation history, an agent definition, and references +to the shared MCP / A2A servers (no per-session subprocess spawning). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from contextlib import AsyncExitStack +from typing import Any, AsyncGenerator + +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentDefinition, AgentRegistry +from opensensa.web.call_graph import WebCallGraph + +logger = logging.getLogger("opensensa.web") + + +class ChatSession: + """One browser chat session with a single agent.""" + + def __init__( + self, + session_id: str, + agent_def: AgentDefinition, + config: AppConfig, + registry: AgentRegistry, + mcp_server_url: str, + ) -> None: + self.session_id = session_id + self.agent_def = agent_def + self.config = config + self.registry = registry + self.mcp_server_url = mcp_server_url + self.conversation_history: list[Any] = [] + self.created_at: float = time.time() + + def to_dict(self) -> dict[str, Any]: + return { + "session_id": self.session_id, + "agent_name": self.agent_def.name, + "history_length": len(self.conversation_history), + "created_at": self.created_at, + } + + +def _make_web_hooks(call_graph: WebCallGraph): + """Create RunHooks that push events into a WebCallGraph. + + Returns None if the agents SDK doesn't expose RunHooks. + """ + try: + from agents import RunHooks # type: ignore[attr-defined] + + class _WebHooks(RunHooks): + def __init__(self, graph: WebCallGraph): + self._graph = graph + self._llm_start_time: float = 0.0 + self._tool_start_times: dict[str, float] = {} + self._agent_start_time: float = 0.0 + + async def on_llm_start(self, context, agent, system_prompt, input_items): + self._llm_start_time = time.monotonic() + agent_name = getattr(agent, "name", str(agent)) + self._graph.start_llm(agent_name) + + async def on_llm_end(self, context, agent, response): + duration_ms = int((time.monotonic() - self._llm_start_time) * 1000) + usage = getattr(response, "usage", None) + total_tokens = 0 + if usage: + total_tokens = getattr(usage, "total_tokens", 0) + agent_name = getattr(agent, "name", str(agent)) + self._graph.end_llm( + agent_name=agent_name, + duration_ms=duration_ms, + tokens=total_tokens, + ) + + async def on_tool_start(self, context, agent, tool): + name = getattr(tool, "name", None) or str(tool) + self._tool_start_times[name] = time.monotonic() + self._graph.start_tool(name) + + async def on_tool_end(self, context, agent, tool, result): + name = getattr(tool, "name", None) or str(tool) + duration_ms = int( + (time.monotonic() - self._tool_start_times.pop(name, time.monotonic())) * 1000 + ) + text = str(result) if result else "" + self._graph.end_tool(name, duration_ms=duration_ms, result=text) + + async def on_agent_start(self, context, agent): + self._agent_start_time = time.monotonic() + agent_name = getattr(agent, "name", str(agent)) + self._graph.start_agent(agent_name) + + async def on_agent_end(self, context, agent, output): + duration_ms = int((time.monotonic() - self._agent_start_time) * 1000) + agent_name = getattr(agent, "name", str(agent)) + self._graph.end_agent(agent_name, duration_ms=duration_ms) + + async def on_handoff(self, context, from_agent, to_agent): + from_name = getattr(from_agent, "name", str(from_agent)) + to_name = getattr(to_agent, "name", str(to_agent)) + self._graph.start_delegation(from_name, to_name) + + return _WebHooks(graph=call_graph) + except ImportError: + return None + except Exception: + logger.warning("Failed to create web RunHooks", exc_info=True) + return None + + +class ChatManager: + """Manages multiple concurrent browser chat sessions.""" + + def __init__( + self, + config: AppConfig, + registry: AgentRegistry, + mcp_server_url: str, + ) -> None: + self.config = config + self.registry = registry + self.mcp_server_url = mcp_server_url + self._sessions: dict[str, ChatSession] = {} + + def create_session(self, agent_name: str) -> ChatSession: + """Create a new chat session for the given agent.""" + agent_def = self.registry.get(agent_name) + if not agent_def: + raise ValueError(f"Agent '{agent_name}' not found") + + session_id = str(uuid.uuid4())[:8] + session = ChatSession( + session_id=session_id, + agent_def=agent_def, + config=self.config, + registry=self.registry, + mcp_server_url=self.mcp_server_url, + ) + self._sessions[session_id] = session + return session + + def get_session(self, session_id: str) -> ChatSession | None: + return self._sessions.get(session_id) + + def delete_session(self, session_id: str) -> bool: + return self._sessions.pop(session_id, None) is not None + + def list_sessions(self) -> list[dict]: + return [s.to_dict() for s in self._sessions.values()] + + async def send_message( + self, + session_id: str, + user_input: str, + *, + context_headers: dict[str, str] | None = None, + ) -> AsyncGenerator[str, None]: + """Run one agent turn and yield SSE events. + + This is an async generator — the SSE endpoint iterates it. + + Args: + context_headers: Optional per-request headers extracted from the + client HTTP request (e.g. X-Document-URL, X-Project). These + override the static defaults from the agent definition and are + forwarded to MCP tool servers. + """ + session = self._sessions.get(session_id) + if not session: + yield f'data: {{"event": "turn_error", "error": "Session not found"}}\n\n' + return + + # Create WebCallGraph + hooks for this turn + call_graph = WebCallGraph(agent_name=session.agent_def.name) + hooks = _make_web_hooks(call_graph) + + # Start the agent turn in a background task + agent_task = asyncio.create_task( + self._run_agent_turn(session, user_input, call_graph, hooks, context_headers=context_headers) + ) + + # Stream events from the call graph + async for event_str in call_graph.events(): + yield event_str + + # Ensure the task is done (it should be — call_graph.finish() was called) + try: + await agent_task + except Exception: + pass # errors already emitted via call_graph.error() + + async def _run_agent_turn( + self, + session: ChatSession, + user_input: str, + call_graph: WebCallGraph, + hooks: Any, + *, + context_headers: dict[str, str] | None = None, + ) -> None: + """Execute one agent turn. Pushes events to call_graph, finishes with response or error.""" + try: + from agents import Runner + from opensensa.orchestrator.agent_builder import build_agent + + async with AsyncExitStack() as exit_stack: + # Infrastructure headers required by the MCP tool server + # (X-Conversation-Id is mandatory for tool logging). + mcp_headers: dict[str, str] = { + "X-Conversation-Id": session.session_id, + "X-Agent-Id": session.agent_def.name, + "X-Agent-Depth": "0", + } + # Per-request context headers from the client (e.g. X-Document-URL) + if context_headers: + mcp_headers.update(context_headers) + logger.info( + "_run_agent_turn: agent=%s context_headers=%s mcp_headers=%s", + session.agent_def.name, + context_headers, + mcp_headers, + ) + + remote_agents = [ + {"url": ra.url, "name": getattr(ra, "name", "")} + for ra in session.config.remote_agents + ] + + agent = await build_agent( + agent_def=session.agent_def, + config=session.config, + mcp_server_url=session.mcp_server_url, + exit_stack=exit_stack, + mcp_headers=mcp_headers or None, + agent_registry=session.registry, + remote_agents=remote_agents or None, + call_graph=call_graph, + ) + + # Build input + if session.conversation_history: + input_items = session.conversation_history.copy() + input_items.append({"role": "user", "content": user_input}) + run_input: Any = input_items + else: + run_input = user_input + + result = await asyncio.wait_for( + Runner.run( + starting_agent=agent, + input=run_input, + hooks=hooks, + max_turns=25, + ), + timeout=300.0, + ) + + # Persist history + session.conversation_history = result.to_input_list() + + # Extract response text + response = "" + if result.final_output: + response = str(result.final_output) + elif result.new_items: + parts = [] + for item in result.new_items: + for attr in ("text", "output"): + if hasattr(item, attr): + parts.append(str(getattr(item, attr))) + break + response = "\n".join(parts) if parts else "Agent completed with no output." + else: + response = "Agent completed with no output." + + call_graph.finish(response=response) + + except asyncio.TimeoutError: + call_graph.error("Request timed out after 5 minutes.") + except Exception as exc: + logger.error("Agent turn failed", exc_info=True) + msg = str(exc).strip() + lower = msg.lower() + + if any(kw in lower for kw in ("401", "unauthorized", "api key", "authentication")): + call_graph.error("Authentication failed — check your API key.") + elif any(kw in lower for kw in ("429", "rate limit", "rate_limit", "quota")): + call_graph.error("Rate-limited — wait a moment and try again.") + elif any(kw in lower for kw in ("model_not_found", "model not found", "does not exist")): + call_graph.error("Model not found — check the model name in your agent config.") + elif any(kw in lower for kw in ("failed to connect to mcp", "session terminated", "mcp")): + call_graph.error("MCP connection failed — check the MCP server URL and ensure it is running.") + elif any(kw in lower for kw in ("connection", "timed out", "timeout", "unreachable")): + call_graph.error("Connection error — unable to reach the LLM provider.") + else: + call_graph.error(msg[:300] if len(msg) > 300 else msg) diff --git a/src/opensensa/web/routes.py b/src/opensensa/web/routes.py new file mode 100644 index 0000000..d91047d --- /dev/null +++ b/src/opensensa/web/routes.py @@ -0,0 +1,332 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""FastAPI routes for the OpenSensa web frontend. + +Provides: + - Static file serving + index.html + - Agent CRUD REST endpoints + - Chat session management + SSE streaming +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any, Optional + +import yaml +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse +from pydantic import BaseModel + +from opensensa.config import AppConfig +from opensensa.orchestrator.agent_registry import AgentRegistry +from opensensa.web.chat_manager import ChatManager + +logger = logging.getLogger("opensensa.web") + +# Directory containing static assets (app.js, styles.css) +_STATIC_DIR = Path(__file__).parent / "static" +_TEMPLATE_DIR = Path(__file__).parent / "templates" + + +# --------------------------------------------------------------------------- +# Pydantic request models +# --------------------------------------------------------------------------- + +class CreateSessionRequest(BaseModel): + agent_name: str + + +class SendMessageRequest(BaseModel): + message: str + + +class CreateAgentRequest(BaseModel): + name: str + description: str + system_prompt: str + model: str = "${default}" + tools: list[str] = [] + sub_agents: list[str] = [] + + +class EditAgentRequest(BaseModel): + description: Optional[str] = None + system_prompt: Optional[str] = None + model: Optional[str] = None + tools: Optional[list[str]] = None + sub_agents: Optional[list[str]] = None + + +# --------------------------------------------------------------------------- +# Router factory +# --------------------------------------------------------------------------- + +def create_web_router( + config: AppConfig, + registry: AgentRegistry, + mcp_server_url: str, +) -> APIRouter: + """Build and return the web frontend APIRouter.""" + + router = APIRouter() + chat_manager = ChatManager( + config=config, + registry=registry, + mcp_server_url=mcp_server_url, + ) + agents_dir = Path(config.agents.directory).resolve() + + # -- Static files + index ----------------------------------------------- + + @router.get("/web", response_class=HTMLResponse) + async def serve_index(): + """Serve the main chat UI.""" + index_file = _TEMPLATE_DIR / "index.html" + if not index_file.exists(): + raise HTTPException(status_code=404, detail="index.html not found") + return HTMLResponse(content=index_file.read_text(encoding="utf-8")) + + @router.get("/web/static/{file_path:path}") + async def serve_static(file_path: str): + """Serve static JS/CSS files.""" + full_path = _STATIC_DIR / file_path + if not full_path.exists() or not full_path.is_file(): + raise HTTPException(status_code=404, detail="Not found") + # Determine content type + suffix = full_path.suffix.lower() + content_types = { + ".js": "application/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + } + media_type = content_types.get(suffix, "application/octet-stream") + return FileResponse(full_path, media_type=media_type) + + # -- Agent listing (reuses registry) ------------------------------------- + + @router.get("/api/agents") + async def list_agents(): + """List all available agents.""" + registry.scan() # Re-scan to pick up new/edited agents + agents = registry.list_agents() + return [ + { + "name": a.name, + "description": a.description, + "model": a.model, + "tools": a.tools, + "sub_agents": getattr(a, "sub_agents", []) or [], + "context_headers": getattr(a, "context_headers", []) or [], + "skills": [ + {"id": s.id, "name": s.name, "description": s.description} + for s in a.skills + ], + } + for a in agents + ] + + @router.get("/api/agents/{name}") + async def get_agent(name: str): + """Get details of a single agent, including system prompt.""" + registry.scan() + agent_def = registry.get(name) + if not agent_def: + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + return { + "name": agent_def.name, + "description": agent_def.description, + "model": agent_def.model, + "tools": agent_def.tools, + "sub_agents": getattr(agent_def, "sub_agents", []) or [], + "system_prompt": agent_def.system_prompt, + "skills": [ + {"id": s.id, "name": s.name, "description": s.description} + for s in agent_def.skills + ], + } + + # -- Agent CRUD ---------------------------------------------------------- + + @router.post("/api/agents", status_code=201) + async def create_agent(req: CreateAgentRequest): + """Create a new agent .md file.""" + agents_dir.mkdir(parents=True, exist_ok=True) + + # Validate name + name = req.name + if len(name) <= 2 or not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$", name): + safe_name = re.sub(r"[^a-z0-9-]", "-", name.lower()).strip("-") + if not safe_name or len(safe_name) < 2: + raise HTTPException(status_code=400, detail=f"Invalid agent name: '{name}'") + name = safe_name + + file_path = agents_dir / f"{name}.md" + if file_path.exists(): + raise HTTPException(status_code=409, detail=f"Agent '{name}' already exists") + + frontmatter: dict[str, Any] = { + "name": name, + "description": req.description, + "model": req.model, + } + if req.tools: + frontmatter["tools"] = req.tools + if req.sub_agents: + frontmatter["sub_agents"] = req.sub_agents + + yaml_block = yaml.dump(frontmatter, default_flow_style=False, sort_keys=False).strip() + content = f"---\n{yaml_block}\n---\n\n{req.system_prompt.strip()}\n" + file_path.write_text(content, encoding="utf-8") + + registry.scan() + return {"status": "created", "name": name} + + @router.put("/api/agents/{name}") + async def edit_agent(name: str, req: EditAgentRequest): + """Edit an existing agent.""" + import frontmatter as fm + + file_path = agents_dir / f"{name}.md" + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + + try: + post = fm.load(str(file_path)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to parse agent file: {e}") + + meta = post.metadata + updated_fields: list[str] = [] + + if req.description is not None: + meta["description"] = req.description + updated_fields.append("description") + if req.model is not None: + meta["model"] = req.model + updated_fields.append("model") + if req.tools is not None: + meta["tools"] = req.tools + updated_fields.append("tools") + if req.sub_agents is not None: + meta["sub_agents"] = req.sub_agents + updated_fields.append("sub_agents") + + body = post.content.strip() + if req.system_prompt is not None: + body = req.system_prompt.strip() + updated_fields.append("system_prompt") + + if not updated_fields: + return {"status": "no_changes", "name": name} + + yaml_block = yaml.dump(meta, default_flow_style=False, sort_keys=False).strip() + content = f"---\n{yaml_block}\n---\n\n{body}\n" + file_path.write_text(content, encoding="utf-8") + + registry.scan() + return {"status": "updated", "name": name, "updated_fields": updated_fields} + + @router.delete("/api/agents/{name}") + async def delete_agent(name: str): + """Delete an agent .md file.""" + file_path = agents_dir / f"{name}.md" + if not file_path.exists(): + raise HTTPException(status_code=404, detail=f"Agent '{name}' not found") + + try: + file_path.unlink() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") + + registry.scan() + return {"status": "deleted", "name": name} + + # -- Chat sessions ------------------------------------------------------- + + @router.post("/api/chat/sessions") + async def create_session(req: CreateSessionRequest): + """Create a new chat session.""" + try: + session = chat_manager.create_session(req.agent_name) + return session.to_dict() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @router.get("/api/chat/sessions") + async def list_sessions(): + """List active chat sessions.""" + return chat_manager.list_sessions() + + @router.delete("/api/chat/sessions/{session_id}") + async def delete_session(session_id: str): + """Delete a chat session.""" + if not chat_manager.delete_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"status": "deleted"} + + @router.post("/api/chat/sessions/{session_id}/reset") + async def reset_session(session_id: str): + """Reset conversation history for a session.""" + session = chat_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + session.conversation_history = [] + return {"status": "reset", "session_id": session_id} + + @router.post("/api/chat/sessions/{session_id}/messages") + async def send_message(session_id: str, req: SendMessageRequest, request: Request): + """Send a message to the agent. Returns an SSE stream.""" + session = chat_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Extract context headers declared by the agent from the client request. + # e.g. healthbuddy declares X-Document-URL and X-Project — if the + # client sends those headers they get forwarded to MCP tool servers. + context_headers: dict[str, str] = {} + declared = session.agent_def.context_headers + logger.info( + "send_message: session=%s agent=%s declared_context_headers=%s", + session_id, session.agent_def.name, declared, + ) + if declared: + for header_name in declared: + value = request.headers.get(header_name) + logger.info( + " header %s → %s", + header_name, repr(value), + ) + if value: + context_headers[header_name] = value + logger.info("send_message: resolved context_headers=%s", context_headers) + + return StreamingResponse( + chat_manager.send_message(session_id, req.message, context_headers=context_headers or None), + media_type="text/event-stream; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + return router diff --git a/src/opensensa/web/static/app.js b/src/opensensa/web/static/app.js new file mode 100644 index 0000000..b3376c1 --- /dev/null +++ b/src/opensensa/web/static/app.js @@ -0,0 +1,969 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================================================================== */ + +// @ts-check +/* ── OpenSensa · ES6 Module — mount(rootEl, options) ───────── */ + +/** + * @typedef {Object} OpenSensaOptions + * @property {string} [baseUrl=""] — API base URL prefix (e.g. "https://my-server.com") + * @property {string} [storagePrefix="opensensa"] — localStorage key prefix (for multi-instance) + * @property {any} [marked] — marked.js instance (falls back to window.marked) + * @property {any} [hljs] — highlight.js instance (falls back to window.hljs) + */ + +/** + * @typedef {{ + * name: string, description: string, model: string, + * tools: string[], sub_agents: string[], + * context_headers: string[], + * sidebarEl: HTMLElement|null, + * sessionId: string|null, + * messages: Array<{role:string, content:string}>, + * isSending: boolean, + * activeTools: Map, + * thinkingEl: HTMLElement|null, + * }} AgentState + * + * @typedef {{ + * id: string, from: string, to: string, + * nodeEl: HTMLElement|null, + * messagesEl: HTMLElement|null, + * activityEl: HTMLElement|null, + * response: string, + * thinkingEl: HTMLElement|null, + * activeTools: Map, + * children: DelegationState[], + * }} DelegationState + */ + +/* ════════════════════════════════════════════════════════════ + HTML Template (injected into rootEl) + ════════════════════════════════════════════════════════════ */ +const TEMPLATE = ` + +
+
+ +
+
+ +
+
+ + +
+ + + + +
+
+ + + +

Select an agent to start chatting

+ Or create a new agent with the button above +
+ + +
+ + + + + + +
+ + +`; + +/* ════════════════════════════════════════════════════════════ + mount() — public entry point + ════════════════════════════════════════════════════════════ */ + +/** + * Mount the OpenSensa Agent Chat UI into a container element. + * + * @param {HTMLElement} rootEl — the container element (any div) + * @param {OpenSensaOptions} [options={}] + * @returns {{ destroy: () => void }} + */ +export default function mount(rootEl, options = {}) { + const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); + const storagePrefix = options.storagePrefix || "opensensa"; + const marked = options.marked || /** @type {any} */ (window)["marked"]; + const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; + + // ── Inject HTML ────────────────────────────────────────── + rootEl.classList.add("root"); + rootEl.innerHTML = TEMPLATE; + + // ── Scoped query helper ────────────────────────────────── + const $ = (/** @type {string} */ s, /** @type {ParentNode} */ p = rootEl) => p.querySelector(s); + const ref = (/** @type {string} */ name) => /** @type {HTMLElement} */($(`[data-ref="${name}"]`)); + + // ── DOM Refs ───────────────────────────────────────────── + const sidebarList = ref("sidebar-list"); + const sidebar = /** @type {HTMLElement} */ ($(".sidebar")); + const emptyState = ref("empty-state"); + const primaryChat = ref("primary-chat"); + const chatHeader = ref("chat-header"); + const chatAgentName = ref("chat-agent-name"); + const chatAgentModel = ref("chat-agent-model"); + const chatMessages = ref("chat-messages"); + const chatMessagesInner = ref("chat-messages-inner"); + const chatActivity = ref("chat-activity"); + const contextHeadersBar = ref("context-headers-bar"); + const chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); + const chatSendBtn = ref("chat-send-btn"); + const treePanelHint = ref("tree-panel-hint"); + const treeLinkArrow = ref("tree-link-arrow"); + const delegationTree = ref("delegation-tree"); + const modalOverlay = ref("modal-overlay"); + const modalTitle = ref("modal-title"); + const agentForm = /** @type {HTMLFormElement} */ (ref("agent-form")); + const afName = /** @type {HTMLInputElement} */ (ref("af-name")); + const afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); + const afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); + const afModel = /** @type {HTMLInputElement} */ (ref("af-model")); + const afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); + const afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); + const afSubmit = ref("af-submit"); + const afDelete = ref("af-delete"); + const afCancel = ref("af-cancel"); + + // ── State ──────────────────────────────────────────────── + /** @type {Map} */ + const agents = new Map(); + /** @type {string|null} */ + let selectedAgent = null; + /** @type {Map} */ + const activeDelegations = new Map(); + /** @type {string|null} */ + let editingAgent = null; + let destroyed = false; + + // ── Helpers ────────────────────────────────────────────── + const api = (/** @type {string} */ path, /** @type {RequestInit} */ opts = {}) => + fetch(baseUrl + path, { headers: { "Content-Type": "application/json" }, ...opts }); + + marked.setOptions({ + highlight: (/** @type {string} */ code, /** @type {string} */ lang) => { + if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value; + return hljs.highlightAuto(code).value; + }, + breaks: true, + }); + + // ── Namespaced localStorage ────────────────────────────── + function storageGet(/** @type {string} */ key) { + try { return localStorage.getItem(`${storagePrefix}-${key}`); } catch { return null; } + } + function storageSet(/** @type {string} */ key, /** @type {string} */ val) { + try { localStorage.setItem(`${storagePrefix}-${key}`, val); } catch { } + } + + /* ════════════════════════════════════════════════════════════ + SIDEBAR — Load & Select Agents + ════════════════════════════════════════════════════════════ */ + + function createSidebarEntry(/** @type {{name:string, description:string, model:string}} */ agent) { + const el = document.createElement("div"); + el.className = "sidebar-agent"; + el.dataset.agent = agent.name; + el.innerHTML = ` + + + `; + + el.addEventListener("click", (e) => { + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent-edit")) return; + selectAgent(agent.name); + }); + + const editBtn = $(".sidebar-agent-edit", el); + editBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + openEditModal(agent.name); + }); + + return el; + } + + async function loadAgents() { + if (destroyed) return; + try { + const res = await api("/api/agents"); + const agentList = await res.json(); + console.log("[opensensa] loadAgents response:", agentList.map((/** @type {any} */ a) => ({ name: a.name, context_headers: a.context_headers }))); + + if (!Array.isArray(agentList) || agentList.length === 0) { + emptyState.classList.remove("hidden"); + primaryChat.classList.add("hidden"); + return; + } + + const existing = new Set(agents.keys()); + + agentList.forEach((/** @type {any} */ a) => { + if (agents.has(a.name)) { existing.delete(a.name); return; } + + const sidebarEl = createSidebarEntry(a); + sidebarList.appendChild(sidebarEl); + + /** @type {AgentState} */ + const state = { + name: a.name, + description: a.description || "", + model: a.model || "", + tools: a.tools || [], + sub_agents: a.sub_agents || [], + context_headers: a.context_headers || [], + sidebarEl, + sessionId: null, + messages: [], + isSending: false, + activeTools: new Map(), + thinkingEl: null, + }; + agents.set(a.name, state); + }); + + for (const gone of existing) { + const s = agents.get(gone); + if (s?.sidebarEl) s.sidebarEl.remove(); + agents.delete(gone); + } + + // No agent selected by default — user clicks to choose + } catch (err) { + console.error("opensensa loadAgents:", err); + } + } + + function selectAgent(/** @type {string} */ name) { + const agent = agents.get(name); + if (!agent) return; + + sidebar.classList.add("collapsed"); + sidebar.classList.remove("expanded"); + + selectedAgent = name; + storageSet("selected", name); + + for (const a of agents.values()) a.sidebarEl?.classList.toggle("active", a.name === name); + + emptyState.classList.add("hidden"); + primaryChat.classList.remove("hidden"); + + primaryChat.classList.remove("panel-enter"); + void primaryChat.offsetWidth; + primaryChat.classList.add("panel-enter"); + + chatAgentName.textContent = name; + chatAgentModel.textContent = agent.model || "default"; + + chatMessagesInner.innerHTML = ""; + for (const msg of agent.messages) appendMessageToEl(chatMessagesInner, msg.role, msg.content); + chatMessages.scrollTop = chatMessages.scrollHeight; + + chatActivity.innerHTML = ""; + delegationTree.innerHTML = ""; + updateTreePanelVisibility(); + + chatInput.placeholder = `Ask ${name}…`; + renderContextHeaders(agent); + chatInput.focus(); + } + + function updateTreePanelVisibility() { + const hasDelegations = delegationTree.children.length > 0; + treeLinkArrow.classList.toggle("visible", hasDelegations); + treePanelHint.style.display = hasDelegations ? "none" : "block"; + } + + /* ════════════════════════════════════════════════════════════ + CHAT — Messages + Sending + ════════════════════════════════════════════════════════════ */ + + function appendMessageToEl(/** @type {HTMLElement} */ container, /** @type {string} */ role, /** @type {string} */ content) { + const div = document.createElement("div"); + if (role === "user") { + div.className = "chat-msg user"; + div.textContent = content; + } else if (role === "agent") { + div.className = "chat-msg agent"; + div.innerHTML = marked.parse(content); + } else if (role === "error") { + div.className = "chat-msg error-msg"; + div.textContent = content; + } else { + div.className = "chat-msg system-msg"; + div.textContent = content; + } + container.appendChild(div); + } + + function addMessage(/** @type {string} */ role, /** @type {string} */ content) { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent) return; + agent.messages.push({ role, content }); + appendMessageToEl(chatMessagesInner, role, content); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + function showThinking() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent || agent.thinkingEl) return; + const el = document.createElement("div"); + el.className = "chat-thinking"; + el.innerHTML = ""; + chatMessagesInner.appendChild(el); + agent.thinkingEl = el; + const dot = $(".chat-status-dot", chatHeader); + if (dot) dot.classList.add("thinking"); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + function hideThinking() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent) return; + if (agent.thinkingEl) { agent.thinkingEl.remove(); agent.thinkingEl = null; } + const dot = $(".chat-status-dot", chatHeader); + if (dot) dot.classList.remove("thinking"); + } + + async function ensureSession(/** @type {AgentState} */ agent) { + if (agent.sessionId) return agent.sessionId; + const res = await api("/api/chat/sessions", { + method: "POST", body: JSON.stringify({ agent_name: agent.name }), + }); + const data = await res.json(); + agent.sessionId = data.session_id; + return agent.sessionId; + } + + /* ── Context-header inputs (e.g. X-Document-URL for healthbuddy) ── */ + + /** + * Render input fields above the chat input for each declared context_header. + * @param {AgentState} agent + */ + function renderContextHeaders(agent) { + contextHeadersBar.innerHTML = ""; + const keys = agent.context_headers || []; + console.log("[opensensa] renderContextHeaders", agent.name, "keys:", keys); + if (!keys.length) { + contextHeadersBar.classList.add("hidden"); + return; + } + contextHeadersBar.classList.remove("hidden"); + for (const headerName of keys) { + const label = headerName.replace(/^X-/i, "").replace(/-/g, " "); + const wrapper = document.createElement("div"); + wrapper.className = "ctx-header-field"; + wrapper.innerHTML = ``; + const input = document.createElement("input"); + input.type = "text"; + input.className = "ctx-header-input"; + input.placeholder = headerName; + input.dataset.headerName = headerName; + // Restore from localStorage if previously set + const stored = storageGet(`ctx:${agent.name}:${headerName}`); + if (stored) input.value = stored; + input.addEventListener("change", () => { + storageSet(`ctx:${agent.name}:${headerName}`, input.value); + }); + wrapper.appendChild(input); + contextHeadersBar.appendChild(wrapper); + } + } + + /** + * Read current context-header input values and return as a headers object. + * @returns {Record} + */ + function getContextHeaderValues() { + /** @type {Record} */ + const headers = {}; + contextHeadersBar.querySelectorAll("input.ctx-header-input").forEach((/** @type {HTMLInputElement} */ el) => { + const name = el.dataset.headerName; + if (name && el.value.trim()) headers[name] = el.value.trim(); + }); + console.log("[opensensa] getContextHeaderValues →", headers); + return headers; + } + + async function sendMessage() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent || agent.isSending) return; + const text = chatInput.value.trim(); + if (!text) return; + + agent.isSending = true; + chatInput.value = ""; + addMessage("user", text); + + try { + const sid = await ensureSession(agent); + showThinking(); + + // Collect context headers from the UI inputs (e.g. X-Document-URL) + const extraHeaders = getContextHeaderValues(); + const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; + console.log("[opensensa] sendMessage fetch headers:", allHeaders); + + const res = await fetch(baseUrl + `/api/chat/sessions/${sid}/messages`, { + method: "POST", + headers: allHeaders, + body: JSON.stringify({ message: text }), + }); + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6); + if (raw === "[DONE]") continue; + try { handleSSEEvent(JSON.parse(raw)); } catch { } + } + } + } + } catch (err) { + hideThinking(); + addMessage("error", "Network error: " + /** @type {Error} */ (err).message); + } + + agent.isSending = false; + hideThinking(); + clearToolChips(); + } + + /* ════════════════════════════════════════════════════════════ + TOOL CHIPS + ════════════════════════════════════════════════════════════ */ + + function showToolChip(/** @type {string} */ nodeId, /** @type {string} */ toolName, /** @type {string} */ status, /** @type {number|undefined} */ durationMs, /** @type {HTMLElement|null} */ targetEl, /** @type {Map|null} */ targetMap) { + const area = targetEl || chatActivity; + const toolMap = targetMap || agents.get(selectedAgent || "")?.activeTools; + if (!area || !toolMap) return; + + let chip = toolMap.get(nodeId); + if (!chip) { + chip = document.createElement("div"); + chip.className = "tool-chip running"; + chip.innerHTML = `🔧${toolName}`; + area.appendChild(chip); + toolMap.set(nodeId, chip); + } + + if (status === "complete" || status === "completed") { + chip.className = "tool-chip complete"; + const ms = durationMs != null ? ` · ${durationMs}ms` : ""; + chip.innerHTML = `${toolName}${ms}`; + setTimeout(() => { toolMap.delete(nodeId); }, 2000); + } else if (status === "failed") { + chip.className = "tool-chip failed"; + chip.innerHTML = `${toolName}`; + toolMap.delete(nodeId); + } + } + + function clearToolChips() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (agent) agent.activeTools.clear(); + setTimeout(() => { chatActivity.innerHTML = ""; }, 2000); + } + + /* ════════════════════════════════════════════════════════════ + DELEGATION TREE + ════════════════════════════════════════════════════════════ */ + + function createDelegationNode(/** @type {string} */ nodeId, /** @type {string} */ fromAgent, /** @type {string} */ toAgent, /** @type {string} */ message, /** @type {HTMLElement} */ parentContainer) { + const node = document.createElement("div"); + node.className = "delegation-node"; + node.dataset.nodeId = nodeId; + + node.innerHTML = ` + + + + +
+
+
+ ${toAgent} + from ${fromAgent} +
+
+
+
`; + + parentContainer.appendChild(node); + + const msgsEl = /** @type {HTMLElement} */ ($(".delegation-card-messages", node)); + const activityEl = /** @type {HTMLElement} */ ($(".delegation-card-activity", node)); + + if (message) { + const label = document.createElement("div"); + label.className = "delegation-from-label"; + label.textContent = `↗ Query from ${fromAgent}`; + msgsEl.appendChild(label); + + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg user"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + msgDiv.textContent = message; + msgsEl.appendChild(msgDiv); + } + + const thinkingEl = document.createElement("div"); + thinkingEl.className = "chat-thinking"; + thinkingEl.innerHTML = ""; + msgsEl.appendChild(thinkingEl); + + /** @type {DelegationState} */ + const state = { + id: nodeId, from: fromAgent, to: toAgent, + nodeEl: node, messagesEl: msgsEl, activityEl, + response: "", thinkingEl, activeTools: new Map(), children: [], + }; + activeDelegations.set(nodeId, state); + + const targetAgent = agents.get(toAgent); + if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.add("delegating"); + + updateTreePanelVisibility(); + setTimeout(() => { node.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, 100); + return state; + } + + function endDelegationNode(/** @type {string} */ nodeId) { + const del = activeDelegations.get(nodeId); + if (!del) return; + + if (del.thinkingEl) { del.thinkingEl.remove(); del.thinkingEl = null; } + + if (del.response && del.messagesEl) { + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg agent"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + msgDiv.innerHTML = marked.parse(del.response); + del.messagesEl.appendChild(msgDiv); + } + + const card = del.nodeEl ? $(".delegation-card", del.nodeEl) : null; + if (card) { card.classList.remove("active"); card.classList.add("completed"); } + + const arrow = del.nodeEl ? $(".delegation-connector-arrow", del.nodeEl) : null; + if (arrow) { + arrow.innerHTML = ` + + `; + } + + const targetAgent = agents.get(del.to); + if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.remove("delegating"); + + activeDelegations.delete(nodeId); + updateTreePanelVisibility(); + } + + function getDelegationChildContainer(/** @type {DelegationState} */ parentDel) { + if (!parentDel.nodeEl) return delegationTree; + let nested = /** @type {HTMLElement|null} */ ($(".delegation-nested", parentDel.nodeEl)); + if (!nested) { + nested = document.createElement("div"); + nested.className = "delegation-nested delegation-tree"; + parentDel.nodeEl.appendChild(nested); + } + return nested; + } + + /* ════════════════════════════════════════════════════════════ + SSE EVENT ROUTER + ════════════════════════════════════════════════════════════ */ + + function findInTree(/** @type {any[]} */ tree, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { + for (const n of tree) { + if (n.id === nodeId) return { node: n, ancestors }; + if (n.children?.length) { + const r = findInTree(n.children, nodeId, [...ancestors, n]); + if (r) return r; + } + } + return null; + } + + function getDelegationForEvent(/** @type {any[]} */ tree, /** @type {string} */ nodeId) { + if (!nodeId || !tree) return null; + const r = findInTree(tree, nodeId); + if (!r) return null; + for (const anc of r.ancestors) { + if (anc.kind === "delegation") { + const del = activeDelegations.get(anc.id); + if (del) return del; + } + } + return null; + } + + function handleSSEEvent(/** @type {any} */ evt) { + const event = evt.event; + switch (event) { + case "turn_complete": + hideThinking(); + if (evt.response) addMessage("agent", evt.response); + for (const [nid] of activeDelegations) endDelegationNode(nid); + break; + + case "turn_error": + hideThinking(); + addMessage("error", evt.error || "Unknown error"); + for (const [nid] of activeDelegations) endDelegationNode(nid); + break; + + case "tool_start": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (del) showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, del.activityEl, del.activeTools); + else showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, null, null); + break; + } + + case "tool_end": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (del) showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, del.activityEl, del.activeTools); + else showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, null, null); + break; + } + + case "delegation_start": { + const fromAgent = evt.from_agent || selectedAgent || ""; + const toAgent = evt.to_agent; + let parentContainer = delegationTree; + for (const del of activeDelegations.values()) { + if (del.to === fromAgent) { parentContainer = getDelegationChildContainer(del); break; } + } + createDelegationNode(evt.node_id, fromAgent, toAgent, evt.message || "", parentContainer); + break; + } + + case "delegation_end": { + const del = activeDelegations.get(evt.node_id); + if (del) del.response = evt.response || ""; + endDelegationNode(evt.node_id); + break; + } + + case "llm_start": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (!del) showThinking(); + break; + } + + case "llm_end": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (!del) hideThinking(); + break; + } + + default: break; + } + } + + /* ════════════════════════════════════════════════════════════ + AGENT CRUD MODAL + ════════════════════════════════════════════════════════════ */ + + function openCreateModal() { + editingAgent = null; + agentForm.reset(); + afName.disabled = false; + afSubmit.textContent = "Create"; + afDelete.classList.add("hidden"); + modalTitle.textContent = "New Agent"; + modalOverlay.classList.remove("hidden"); + } + + async function openEditModal(/** @type {string} */ name) { + editingAgent = name; + afName.disabled = true; + afSubmit.textContent = "Save"; + afDelete.classList.remove("hidden"); + modalTitle.textContent = `Edit ${name}`; + modalOverlay.classList.remove("hidden"); + try { + const res = await api(`/api/agents/${name}`); + const data = await res.json(); + afName.value = name; + afDesc.value = data.description || ""; + afPrompt.value = data.system_prompt || ""; + afModel.value = data.model || ""; + afTools.value = (data.tools || []).join(", "); + afSubs.value = (data.sub_agents || []).join(", "); + } catch { agentForm.reset(); afName.value = name; } + } + + function closeModal() { modalOverlay.classList.add("hidden"); } + + async function submitAgent(/** @type {Event} */ e) { + e.preventDefault(); + const payload = { + name: afName.value.trim(), + description: afDesc.value.trim() || "Agent", + system_prompt: afPrompt.value.trim(), + model: afModel.value.trim() || "${default}", + tools: afTools.value ? afTools.value.split(",").map(s => s.trim()).filter(Boolean) : [], + sub_agents: afSubs.value ? afSubs.value.split(",").map(s => s.trim()).filter(Boolean) : [], + }; + try { + if (editingAgent) await api(`/api/agents/${editingAgent}`, { method: "PUT", body: JSON.stringify(payload) }); + else await api("/api/agents", { method: "POST", body: JSON.stringify(payload) }); + closeModal(); + await reloadAgents(); + } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } + } + + async function deleteCurrentAgent() { + if (!editingAgent || !confirm(`Delete agent "${editingAgent}"?`)) return; + try { + await api(`/api/agents/${editingAgent}`, { method: "DELETE" }); + closeModal(); + const agent = agents.get(editingAgent); + if (agent?.sidebarEl) agent.sidebarEl.remove(); + agents.delete(editingAgent); + if (editingAgent === selectedAgent) { + selectedAgent = null; + if (agents.size > 0) selectAgent(/** @type {string} */(agents.keys().next().value)); + else { emptyState.classList.remove("hidden"); primaryChat.classList.add("hidden"); sidebar.classList.remove("collapsed"); } + } + } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } + } + + async function reloadAgents() { + for (const a of agents.values()) { if (a.sidebarEl) a.sidebarEl.remove(); } + agents.clear(); + selectedAgent = null; + sidebar.classList.remove("collapsed"); + emptyState.classList.remove("hidden"); + primaryChat.classList.add("hidden"); + activeDelegations.clear(); + delegationTree.innerHTML = ""; + chatMessagesInner.innerHTML = ""; + chatActivity.innerHTML = ""; + updateTreePanelVisibility(); + await loadAgents(); + } + + /* ════════════════════════════════════════════════════════════ + EVENT BINDINGS (tracked for cleanup) + ════════════════════════════════════════════════════════════ */ + /** @type {Array<[EventTarget, string, EventListener]>} */ + const listeners = []; + + function on(/** @type {EventTarget} */ el, /** @type {string} */ evt, /** @type {EventListener} */ fn) { + el.addEventListener(evt, fn); + listeners.push([el, evt, fn]); + } + + on(chatInput, "keydown", (e) => { + if (/** @type {KeyboardEvent} */ (e).key === "Enter" && !/** @type {KeyboardEvent} */ (e).shiftKey) { + e.preventDefault(); sendMessage(); + } + }); + on(chatSendBtn, "click", () => sendMessage()); + on(ref("btn-edit-agent"), "click", () => { if (selectedAgent) openEditModal(selectedAgent); }); + on(ref("btn-add-agent"), "click", openCreateModal); + on(ref("btn-refresh-agents"), "click", async () => { + const btn = ref("btn-refresh-agents"); + btn.classList.add("spinning"); + try { await reloadAgents(); } finally { setTimeout(() => btn.classList.remove("spinning"), 400); } + }); + on(sidebar, "click", (e) => { + if (!sidebar.classList.contains("collapsed")) return; + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent")) return; + sidebar.classList.toggle("expanded"); + }); + on(document, "click", (e) => { + if (!sidebar.classList.contains("expanded")) return; + if (!sidebar.contains(/** @type {Node} */(e.target))) { + sidebar.classList.remove("expanded"); + } + }); + on(ref("modal-close"), "click", closeModal); + on(modalOverlay, "click", (e) => { if (e.target === modalOverlay) closeModal(); }); + on(afCancel, "click", closeModal); + on(agentForm, "submit", submitAgent); + on(afDelete, "click", deleteCurrentAgent); + + /* ════════════════════════════════════════════════════════════ + INIT + ════════════════════════════════════════════════════════════ */ + loadAgents(); + + /* ════════════════════════════════════════════════════════════ + DESTROY — cleanup for unmount + ════════════════════════════════════════════════════════════ */ + return { + destroy() { + destroyed = true; + for (const [el, evt, fn] of listeners) el.removeEventListener(evt, fn); + listeners.length = 0; + rootEl.classList.remove("root"); + rootEl.innerHTML = ""; + }, + }; +} diff --git a/src/opensensa/web/static/styles.css b/src/opensensa/web/static/styles.css new file mode 100644 index 0000000..996681f --- /dev/null +++ b/src/opensensa/web/static/styles.css @@ -0,0 +1,1354 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================================================================== */ + +/* ── OpenSensa · Embeddable ES6 Module Styles ──────────────── */ +/* All selectors scoped under .root — no global resets */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ── Design Tokens (scoped to .root) ─────────────── */ +.root { + /* Palette */ + --bg: #FAF8F5; + --bg-warm: #F4F0EB; + --surface: #FFFFFF; + --surface-alt: #F7F4F0; + --surface-hover: #F0ECE6; + --border: #E8E2D9; + --border-light: #F0EBE4; + --border-focus: #D4A574; + + --accent: #E8751A; + --accent-dark: #D06515; + --accent-light: #F5C9A0; + --accent-muted: #F9E0C5; + --accent-bg: #FFF7F0; + --accent-glow: rgba(232, 117, 26, .18); + + --text: #37352F; + --text-mid: #6B6760; + --text-muted: #A09B93; + --text-faint: #C4BFB7; + + --success: #3B8C5F; + --success-bg: #EDF7F0; + --warning: #C48B2C; + --error: #C4554D; + --error-bg: #FDF2F2; + --info: #5B8DC9; + + /* Layout */ + --toolbar-h: 50px; + --sidebar-w: 20%; + --radius: 16px; + --radius-sm: 12px; + --radius-xs: 8px; + --radius-pill: 100px; + + /* Type */ + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(55, 53, 47, .04), 0 0 0 1px rgba(55, 53, 47, .04); + --shadow-card: 0 1px 4px rgba(55, 53, 47, .06), 0 0 0 1px rgba(55, 53, 47, .05); + --shadow-card-hover: 0 4px 16px rgba(55, 53, 47, .08), 0 0 0 1px rgba(55, 53, 47, .06); + --shadow-card-active: 0 6px 24px rgba(55, 53, 47, .10), 0 0 0 2px var(--accent); + --shadow-lg: 0 12px 40px rgba(55, 53, 47, .12); + + /* Root container styles */ + font-family: var(--font); + font-size: 14px; + color: var(--text); + background: var(--bg); + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.root *, +.root *::before, +.root *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Toolbar ─────────────────────────────────────────────── */ +.toolbar { + height: var(--toolbar-h); + background: var(--surface); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + gap: 10px; + position: relative; + z-index: 200; + flex-shrink: 0; +} + +.toolbar-section { + display: flex; + align-items: center; + gap: 6px; +} + +.toolbar-right { + margin-left: auto; +} + +.logo { + font-size: 16px; + font-weight: 700; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + letter-spacing: -.3px; + user-select: none; +} + +.logo svg { + color: var(--accent); +} + +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 32px; + min-width: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface); + color: var(--text-mid); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all .15s ease; +} + +.tool-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.tool-btn-accent { + background: var(--text); + border-color: var(--text); + color: var(--surface); + font-weight: 600; +} + +.tool-btn-accent:hover { + background: #2C2B27; + border-color: #2C2B27; + color: var(--surface); +} + +/* ══════════════════════════════════════════════════════════ + App Layout: Sidebar + Chat + Tree (3-column) + ══════════════════════════════════════════════════════════ */ +.app-layout { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* ── Sidebar ─────────────────────────────────────────────── */ +.sidebar { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; + transition: flex-basis .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1), box-shadow .3s ease; +} + +.sidebar-header { + padding: 16px 18px 10px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-refresh-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color .2s, background .2s, transform .4s ease; +} + +.sidebar-refresh-btn:hover { + color: var(--accent); + background: var(--bg-hover); +} + +.sidebar-refresh-btn.spinning svg { + animation: spin-once .5s ease-in-out; +} + +@keyframes spin-once { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.sidebar-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.sidebar-list { + flex: 1; + overflow-y: auto; + padding: 0 8px 12px; +} + +.sidebar-list::-webkit-scrollbar { + width: 4px; +} + +.sidebar-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ── Sidebar Collapsed State ─────────────────────────────── */ +.sidebar.collapsed { + flex: 0 0 48px; + width: 48px; + position: relative; + z-index: 50; +} + +.sidebar.collapsed .sidebar-header { + padding: 14px 0 8px; + display: flex; + justify-content: center; +} + +.sidebar.collapsed .sidebar-refresh-btn { + display: none; +} + +.sidebar.collapsed .sidebar-title { + font-size: 0; + width: 20px; + height: 20px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; +} + +.sidebar.collapsed .sidebar-title::after { + content: ''; + display: block; + width: 16px; + height: 2px; + background: var(--text-muted); + border-radius: 2px; + box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); +} + +.sidebar.collapsed .sidebar-list { + display: none; +} + +/* Click-expanded overlay — stays in flex flow so chat adjusts */ +.sidebar.collapsed.expanded { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + box-shadow: var(--shadow-lg); + z-index: 100; +} + +.sidebar.collapsed.expanded .sidebar-header { + padding: 16px 18px 10px; + justify-content: space-between; +} + +.sidebar.collapsed.expanded .sidebar-title { + font-size: 11px; + width: auto; + height: auto; +} + +.sidebar.collapsed.expanded .sidebar-title::after { + display: none; +} + +.sidebar.collapsed.expanded .sidebar-refresh-btn { + display: flex; +} + +.sidebar.collapsed.expanded .sidebar-list { + display: block; + padding: 0 8px 12px; +} + +.sidebar.collapsed .sidebar-header { + cursor: pointer; +} + +/* main-area is already flex:1, no override needed when sidebar collapses */ + +/* ── Sidebar Agent Entry ─────────────────────────────────── */ +.sidebar-agent { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 2px; + border-radius: var(--radius-xs); + cursor: pointer; + transition: background .2s ease, box-shadow .2s ease, transform .15s ease; + position: relative; + user-select: none; +} + +.sidebar-agent:active { + transform: scale(.98); +} + +.sidebar-agent:hover { + background: var(--surface-hover); +} + +.sidebar-agent.active { + background: var(--accent-bg); + box-shadow: inset 3px 0 0 var(--accent); + animation: sidebarSelect .3s ease; +} + +@keyframes sidebarSelect { + from { + background: var(--surface-hover); + transform: translateX(0); + } + + 40% { + transform: translateX(4px); + } + + to { + background: var(--accent-bg); + transform: translateX(0); + } +} + +.sidebar-agent.delegating { + background: var(--success-bg); +} + +.sidebar-agent-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-faint); + flex-shrink: 0; + margin-top: 5px; + transition: background .3s; +} + +.sidebar-agent.active .sidebar-agent-dot { + background: var(--accent); +} + +.sidebar-agent.delegating .sidebar-agent-dot { + background: var(--success); +} + +.sidebar-agent-info { + flex: 1; + min-width: 0; +} + +.sidebar-agent-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -.15px; +} + +.sidebar-agent-desc { + font-size: 11px; + color: var(--text-muted); + display: block; + margin-top: 2px; + line-height: 1.4; +} + +.sidebar-agent-edit { + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-faint); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all .12s; + flex-shrink: 0; +} + +.sidebar-agent:hover .sidebar-agent-edit { + opacity: 1; +} + +.sidebar-agent-edit:hover { + background: var(--surface-alt); + color: var(--text-mid); +} + +/* ══════════════════════════════════════════════════════════ + Centre: Chat Area + ══════════════════════════════════════════════════════════ */ +.main-area { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); + position: relative; + padding: 20px 24px; + transition: flex .3s cubic-bezier(.4, 0, .2, 1); +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} + +.empty-state svg { + opacity: .2; + stroke: var(--text-faint); +} + +.empty-state p { + font-size: 15px; + font-weight: 600; + color: var(--text-mid); +} + +.empty-state span { + font-size: 13px; +} + +/* ── Primary Chat Panel ──────────────────────────────────── */ +.chat-panel { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--border-light); + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + animation: panelSlideIn .25s ease; +} + +@keyframes panelSlideIn { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-panel.panel-enter { + animation: panelEnter .3s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes panelEnter { + 0% { + opacity: 0; + transform: translateX(-16px) scale(.98); + } + + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.chat-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.chat-status-dot.thinking { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +.chat-agent-name { + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: -.2px; +} + +.chat-agent-model { + font-size: 10.5px; + font-weight: 600; + font-family: var(--mono); + color: var(--text-muted); + background: var(--surface-alt); + padding: 2px 7px; + border-radius: 4px; + letter-spacing: .3px; +} + +.icon-btn { + width: 30px; + height: 30px; + border: none; + background: none; + color: var(--text-muted); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.icon-btn:hover { + background: var(--surface-alt); + color: var(--text); +} + +/* ── Chat Messages ───────────────────────────────────────── */ +.chat-messages { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.chat-messages-inner { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Messages */ +.chat-msg { + font-size: 13.5px; + line-height: 1.6; + max-width: 80%; + padding: 10px 14px; + border-radius: var(--radius-sm); + word-break: break-word; + animation: msgUp .2s ease; +} + +@keyframes msgUp { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-msg.user { + align-self: flex-end; + background: var(--text); + color: var(--surface); + border-bottom-right-radius: 4px; +} + +.chat-msg.agent { + align-self: flex-start; + background: var(--surface-alt); + color: var(--text); + border-bottom-left-radius: 4px; + border: 1px solid var(--border-light); +} + +.chat-msg.agent p { + margin: 0 0 .3em; +} + +.chat-msg.agent p:last-child { + margin: 0; +} + +.chat-msg.agent pre { + background: #2C2B27; + color: #E8E2D9; + padding: 10px 12px; + border-radius: 8px; + margin: 6px 0; + overflow-x: auto; + font-size: 12px; + font-family: var(--mono); +} + +.chat-msg.agent code { + font-family: var(--mono); + font-size: .88em; +} + +.chat-msg.agent :not(pre)>code { + background: rgba(55, 53, 47, .06); + padding: 1px 5px; + border-radius: 4px; +} + +.chat-msg.user :not(pre)>code { + background: rgba(255, 255, 255, .15); +} + +.chat-msg.error-msg { + align-self: center; + background: var(--error-bg); + color: var(--error); + font-size: 12.5px; + text-align: center; + border-radius: var(--radius-pill); + padding: 5px 16px; +} + +.chat-msg.system-msg { + align-self: center; + background: none; + color: var(--text-muted); + font-size: 11.5px; + text-align: center; + padding: 2px 8px; +} + +/* Thinking dots */ +.chat-thinking { + display: flex; + gap: 4px; + padding: 8px 12px; + align-self: flex-start; +} + +.chat-thinking span { + width: 6px; + height: 6px; + background: var(--accent-light); + border-radius: 50%; + animation: dots .7s ease-in-out infinite; +} + +.chat-thinking span:nth-child(2) { + animation-delay: .12s; +} + +.chat-thinking span:nth-child(3) { + animation-delay: .24s; +} + +@keyframes dots { + + 0%, + 80%, + 100% { + transform: scale(.5); + opacity: .3; + } + + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ── Chat Activity (Tool Chips) ──────────────────────────── */ +.chat-activity { + min-height: 0; + overflow: hidden; + padding: 0 20px; + transition: padding .2s, min-height .2s; +} + +.chat-activity:not(:empty) { + padding: 6px 20px; + min-height: 32px; +} + +.tool-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + padding: 3px 8px; + border-radius: 6px; + margin: 2px 2px; + animation: chipPop .15s ease; +} + +@keyframes chipPop { + from { + opacity: 0; + transform: scale(.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.tool-chip.running { + background: var(--accent-bg); + color: var(--accent); + border: 1px solid var(--accent-muted); +} + +.tool-chip.complete { + background: var(--success-bg); + color: var(--success); + border: 1px solid #C6E7D0; +} + +.tool-chip.failed { + background: var(--error-bg); + color: var(--error); + border: 1px solid #F5D0CD; +} + +.tool-chip-icon { + font-size: 11px; +} + +.spinner { + width: 9px; + height: 9px; + border: 1.5px solid var(--accent-muted); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .65s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ── Context Headers Bar (above chat input) ──────────────── */ +.context-headers-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--border-light); + background: var(--bg-body); + flex-shrink: 0; +} + +.context-headers-bar.hidden { + display: none; +} + +.ctx-header-field { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 200px; + min-width: 180px; +} + +.ctx-header-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.ctx-header-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 5px 10px; + font-family: var(--font); + font-size: 12.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s; +} + +.ctx-header-input:focus { + border-color: var(--accent); +} + +.ctx-header-input::placeholder { + color: var(--text-faint); + font-size: 11px; +} + +/* ── Chat Input ──────────────────────────────────────────── */ +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 10px 14px; + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s, box-shadow .15s; +} + +.chat-input::placeholder { + color: var(--text-faint); + font-size: 13px; +} + +.chat-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.chat-send-btn { + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius-xs); + background: var(--text); + color: var(--surface); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .15s; + flex-shrink: 0; +} + +.chat-send-btn svg { + width: 15px; + height: 15px; +} + +.chat-send-btn:hover { + background: #2C2B27; +} + +.chat-send-btn:disabled { + opacity: .25; + cursor: not-allowed; +} + +/* ══════════════════════════════════════════════════════════ + Link Arrow (between chat and tree) + ══════════════════════════════════════════════════════════ */ +.tree-link-arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + opacity: 0; + transition: opacity .3s ease; +} + +.tree-link-arrow.visible { + opacity: 1; +} + +/* ══════════════════════════════════════════════════════════ + Right: Delegation Tree Panel + ══════════════════════════════════════════════════════════ */ +.tree-panel { + flex: 0 0 35%; + min-width: 0; + background: var(--surface); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tree-panel-header { + padding: 16px 18px 10px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-light); +} + +.tree-panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.tree-panel-hint { + display: block; + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; + line-height: 1.4; +} + +.tree-panel .delegation-tree { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 10px 20px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar { + width: 4px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ── Delegation Tree Nodes ───────────────────────────────── */ +.delegation-tree { + display: flex; + flex-direction: column; + align-items: stretch; + position: relative; +} + +.delegation-node { + position: relative; + margin-top: 0; + padding-left: 24px; + animation: delegationSlideIn .35s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes delegationSlideIn { + from { + opacity: 0; + transform: translateY(-12px) scale(.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Vertical connector */ +.delegation-node::before { + content: ''; + position: absolute; + left: 12px; + top: -16px; + width: 2px; + height: 32px; + background: var(--accent-light); +} + +/* Horizontal connector */ +.delegation-node::after { + content: ''; + position: absolute; + left: 12px; + top: 16px; + width: 16px; + height: 2px; + background: var(--accent-light); +} + +.delegation-connector-arrow { + position: absolute; + left: 6px; + top: 10px; + width: 14px; + height: 14px; + color: var(--accent); + z-index: 1; +} + +/* ── Delegation mini card ────────────────────────────────── */ +.delegation-card { + background: var(--surface); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--accent-muted); + width: 100%; + overflow: hidden; + transition: border-color .3s, box-shadow .3s; +} + +.delegation-card.active { + border-color: var(--accent); + box-shadow: var(--shadow-card), 0 0 0 3px var(--accent-glow); +} + +.delegation-card.completed { + border-color: var(--border-light); + opacity: .85; +} + +.delegation-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-light); + background: var(--accent-bg); +} + +.delegation-card-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.delegation-card.active .delegation-card-dot { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +.delegation-card.completed .delegation-card-dot { + background: var(--success); +} + +.delegation-card-name { + font-size: 12.5px; + font-weight: 700; + color: var(--text); + flex: 1; + letter-spacing: -.15px; +} + +.delegation-card-from { + font-size: 10px; + color: var(--accent-dark); + font-weight: 500; +} + +.delegation-card-messages { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.delegation-card-messages::-webkit-scrollbar { + width: 3px; +} + +.delegation-card-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.delegation-card-activity { + padding: 0 14px; +} + +.delegation-card-activity:not(:empty) { + padding: 4px 14px 8px; +} + +.delegation-node .delegation-node { + margin-top: 0; +} + +/* ══════════════════════════════════════════════════════════ + Modal + ══════════════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(55, 53, 47, .25); + backdrop-filter: blur(3px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn .12s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal { + background: var(--surface); + border-radius: var(--radius); + width: 460px; + max-height: 85vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border-light); + animation: modalSlide .2s ease; +} + +@keyframes modalSlide { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--border-light); +} + +.modal-header h2 { + font-size: 15px; + font-weight: 700; + letter-spacing: -.2px; +} + +.modal-close { + width: 30px; + height: 30px; + border: none; + background: var(--surface-alt); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.modal-close:hover { + background: var(--error-bg); + color: var(--error); +} + +.modal-body { + padding: 18px 22px; +} + +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--text); + margin-bottom: 5px; + letter-spacing: .2px; +} + +.form-hint { + font-weight: 400; + color: var(--text-muted); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + transition: border-color .15s, box-shadow .15s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.form-group textarea { + min-height: 80px; + resize: vertical; + line-height: 1.5; +} + +.form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 6px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 9px 18px; + border: none; + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all .15s; +} + +.btn-primary { + background: var(--text); + color: var(--surface); +} + +.btn-primary:hover { + background: #2C2B27; +} + +.btn-ghost { + background: none; + color: var(--text-muted); +} + +.btn-ghost:hover { + background: var(--surface-alt); + color: var(--text); +} + +.btn-danger { + background: var(--error); + color: #fff; +} + +.btn-danger:hover { + background: #B04840; +} + +/* ── Utilities ───────────────────────────────────────────── */ +.hidden { + display: none !important; +} + +.delegation-from-label { + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + padding: 2px 0; + display: flex; + align-items: center; + gap: 4px; +} \ No newline at end of file diff --git a/src/opensensa/web/templates/index.html b/src/opensensa/web/templates/index.html new file mode 100644 index 0000000..4ba6928 --- /dev/null +++ b/src/opensensa/web/templates/index.html @@ -0,0 +1,70 @@ + + + + + + + + + OpenSensa — Agent Chat + + + + + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..623f08d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,17 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for OpenSensa.""" diff --git a/tests/test_a2a_integration.py b/tests/test_a2a_integration.py new file mode 100644 index 0000000..b24bbe7 --- /dev/null +++ b/tests/test_a2a_integration.py @@ -0,0 +1,328 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for Phase 6 — A2A executor and server integration. + +Tests the full A2A pipeline: + - FrameworkAgentExecutor with a2a-sdk types + - Orchestrator server with real A2A endpoints + - Agent Card generation via a2a-sdk types + - JSON-RPC SendMessage processing +""" + +import json +import tempfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from opensensa.config import AppConfig, ModelsConfig, ModelConfig, ServerConfig, AgentsConfig, ToolsConfig, LoggingConfig +from opensensa.orchestrator.agent_registry import AgentRegistry, AgentDefinition +from opensensa.orchestrator.server import create_orchestrator_app +from opensensa.a2a.agent_card import build_agent_card + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_agents_dir(): + """Create a temp directory with a test agent .md file.""" + with tempfile.TemporaryDirectory() as d: + agents_dir = Path(d) / "agents" + agents_dir.mkdir() + (agents_dir / "test-agent.md").write_text( + '---\n' + 'name: test-agent\n' + 'description: A simple test agent for integration tests\n' + 'model: test-model\n' + 'tools: []\n' + 'skills:\n' + ' - id: test-skill\n' + ' name: Test Skill\n' + ' description: A test skill for validation\n' + ' tags: [test, validation]\n' + ' examples:\n' + ' - "Run a test"\n' + 'input_modes: ["text/plain"]\n' + 'output_modes: ["text/plain", "application/json"]\n' + '---\n' + '\n' + '# Test Agent\n' + '\n' + 'You are a test agent that echoes inputs.\n' + ) + yield agents_dir + + +@pytest.fixture +def config_with_model(tmp_agents_dir): + """Config with a model registry entry (model won't actually connect).""" + return AppConfig( + models=ModelsConfig( + default="test-model", + registry={ + "test-model": ModelConfig( + base_url="http://localhost:11434/v1", + api_key="test-key", + model_name="test-llm", + ), + }, + ), + server=ServerConfig(host="127.0.0.1", orchestrator_port=8000, mcp_port=8001), + agents=AgentsConfig(directory=str(tmp_agents_dir)), + tools=ToolsConfig(directory=str(tmp_agents_dir.parent / "tools")), + logging=LoggingConfig(level="warning"), + ) + + +@pytest.fixture +def registry(tmp_agents_dir): + """Agent registry pointing at test agents.""" + reg = AgentRegistry(tmp_agents_dir) + reg.scan() + return reg + + +@pytest.fixture +def app(config_with_model, registry): + """FastAPI app with real A2A wiring.""" + return create_orchestrator_app(config_with_model, registry) + + +@pytest.fixture +def client(app): + """TestClient for the orchestrator app.""" + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Agent Card tests (using a2a-sdk types) +# --------------------------------------------------------------------------- + +class TestAgentCard: + def test_build_single_agent_card(self, registry): + agent_def = registry.get("test-agent") + assert agent_def is not None + + card = build_agent_card(agent_def, "http://localhost:8000") + + # Verify it's an a2a-sdk AgentCard (Pydantic model) + from a2a.types import AgentCard + assert isinstance(card, AgentCard) + assert card.name == "test-agent" + assert card.description == "A simple test agent for integration tests" + assert card.url == "http://localhost:8000/agents/test-agent" + assert card.version == "1.0.0" + assert card.capabilities.streaming is True + assert card.capabilities.push_notifications is False + assert len(card.skills) == 1 + assert card.skills[0].id == "test-skill" + assert card.skills[0].name == "Test Skill" + assert "test" in card.skills[0].tags + assert "text/plain" in card.default_input_modes + assert "application/json" in card.default_output_modes + + def test_agent_card_per_agent(self, registry): + """Each agent should get a card with URL pointing to its sub-app.""" + agents = registry.list_agents() + for agent_def in agents: + card = build_agent_card(agent_def, "http://localhost:8000") + assert card.url == f"http://localhost:8000/agents/{agent_def.name}" + assert len(card.skills) >= 1 + + def test_agent_card_serializes_to_json(self, registry): + agent_def = registry.get("test-agent") + card = build_agent_card(agent_def, "http://localhost:8000") + + # Should be serializable via Pydantic + card_json = card.model_dump(mode="json", exclude_none=True) + assert card_json["name"] == "test-agent" + assert isinstance(card_json["skills"], list) + + def test_default_skill_when_none_defined(self, tmp_agents_dir): + """Agent with no skills gets a default skill from its name/description.""" + (tmp_agents_dir / "no-skills.md").write_text( + '---\n' + 'name: bare-agent\n' + 'description: Agent with no skills defined\n' + 'model: test-model\n' + 'tools: []\n' + '---\n' + '\n' + 'You are a bare agent.\n' + ) + reg = AgentRegistry(tmp_agents_dir) + agent_def = reg.get("bare-agent") + card = build_agent_card(agent_def, "http://localhost:8000") + + assert len(card.skills) == 1 + assert card.skills[0].id == "bare-agent" + assert card.skills[0].name == "bare-agent" + assert card.url == "http://localhost:8000/agents/bare-agent" + + +# --------------------------------------------------------------------------- +# Orchestrator server endpoint tests +# --------------------------------------------------------------------------- + +class TestOrchestratorEndpoints: + def test_health(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert "test-agent" in data["agents"] + + def test_agent_card_endpoint(self, client): + resp = client.get("/agents/test-agent/.well-known/agent-card.json") + assert resp.status_code == 200 + card = resp.json() + assert card["name"] == "test-agent" + assert isinstance(card["skills"], list) + assert len(card["skills"]) >= 1 + assert card["capabilities"]["streaming"] is True + + def test_agents_list(self, client): + resp = client.get("/agents") + assert resp.status_code == 200 + agents = resp.json() + assert len(agents) >= 1 + assert agents[0]["name"] == "test-agent" + assert "url" in agents[0] # Per-agent URL should be included + + def test_a2a_send_message(self, client): + """SendMessage should reach the executor and return a Task (even if LLM fails).""" + payload = { + "jsonrpc": "2.0", + "id": "test-req-1", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "Hello agent"}], + "messageId": "msg-test-1", + } + } + } + resp = client.post("/agents/test-agent/", json=payload) + assert resp.status_code == 200 + result = resp.json() + + # Should be a valid JSON-RPC response + assert result["jsonrpc"] == "2.0" + assert result["id"] == "test-req-1" + + # Should have a result (Task) — it may be failed (no real LLM) but it's a proper Task + task = result.get("result") + assert task is not None + assert "id" in task # Task ID + assert "status" in task + # The task should have reached the executor (failed because no real LLM is connected) + # The status.state should be "failed" since the model endpoint is unreachable + assert task["status"]["state"] in ("failed", "completed", "working") + + def test_a2a_send_message_empty_text(self, client): + """SendMessage with empty text should get a failed status.""" + payload = { + "jsonrpc": "2.0", + "id": "test-req-2", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": ""}], + "messageId": "msg-test-2", + } + } + } + resp = client.post("/agents/test-agent/", json=payload) + assert resp.status_code == 200 + result = resp.json() + task = result.get("result") + assert task is not None + assert task["status"]["state"] == "failed" + + def test_a2a_invalid_method(self, client): + """Unknown JSON-RPC method should return an error.""" + payload = { + "jsonrpc": "2.0", + "id": "test-req-3", + "method": "nonexistent/method", + "params": {} + } + resp = client.post("/agents/test-agent/", json=payload) + assert resp.status_code == 200 + result = resp.json() + assert "error" in result + + +# --------------------------------------------------------------------------- +# Executor unit tests +# --------------------------------------------------------------------------- + +class TestFrameworkAgentExecutor: + def test_executor_is_a2a_compatible(self, config_with_model, registry): + """Executor should implement the a2a-sdk AgentExecutor interface.""" + from opensensa.a2a.executor import FrameworkAgentExecutor + from a2a.server.agent_execution.agent_executor import AgentExecutor + + executor = FrameworkAgentExecutor( + agent_name="test-agent", + agent_registry=registry, + config=config_with_model, + ) + assert isinstance(executor, AgentExecutor) + + def test_executor_bound_to_agent(self, config_with_model, registry): + """Executor should be bound to its agent name.""" + from opensensa.a2a.executor import FrameworkAgentExecutor + + executor = FrameworkAgentExecutor( + agent_name="test-agent", + agent_registry=registry, + config=config_with_model, + ) + assert executor._agent_name == "test-agent" + + +# --------------------------------------------------------------------------- +# Task store tests +# --------------------------------------------------------------------------- + +class TestTaskStore: + @pytest.mark.asyncio + async def test_inmemory_task_store_save_and_get(self): + """InMemoryTaskStore from a2a-sdk should work.""" + from opensensa.a2a.task_store import InMemoryTaskStore + from a2a.types import Task, TaskState, TaskStatus, Message, Role, TextPart + + store = InMemoryTaskStore() + + task = Task( + id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.submitted), + kind="task", + ) + await store.save(task) + + retrieved = await store.get("task-1") + assert retrieved is not None + assert retrieved.id == "task-1" + assert retrieved.status.state == TaskState.submitted diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py new file mode 100644 index 0000000..f5cc416 --- /dev/null +++ b/tests/test_agent_registry.py @@ -0,0 +1,121 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for the agent registry.""" + +import tempfile +from pathlib import Path + +from opensensa.orchestrator.agent_registry import AgentDefinition, AgentRegistry + + +def test_parse_agent_definition(): + """Parse a frontmatter .md file into an AgentDefinition.""" + with tempfile.TemporaryDirectory() as tmpdir: + agent_file = Path(tmpdir) / "test-agent.md" + agent_file.write_text("""--- +name: test-agent +description: A test agent +model: gpt-4 +tools: + - add_numbers + - list_tools +skills: + - id: testing + name: Testing + description: Runs tests + tags: [test] +input_modes: ["text/plain"] +output_modes: ["text/plain", "application/json"] +context_headers: + X-Tenant-Id: acme +--- + +# Test Agent + +You are a test agent. Do testing things. + +## Guidelines +- Be thorough +""") + defn = AgentDefinition.from_file(agent_file) + assert defn.name == "test-agent" + assert defn.description == "A test agent" + assert defn.model == "gpt-4" + assert defn.tools == ["add_numbers", "list_tools"] + assert len(defn.skills) == 1 + assert defn.skills[0].id == "testing" + assert defn.skills[0].tags == ["test"] + assert defn.input_modes == ["text/plain"] + assert defn.output_modes == ["text/plain", "application/json"] + assert defn.context_headers == {"X-Tenant-Id": "acme"} + assert "You are a test agent" in defn.system_prompt + assert "---" not in defn.system_prompt # Frontmatter should not leak + + +def test_agent_registry_scan(): + """AgentRegistry should discover .md files and track changes.""" + with tempfile.TemporaryDirectory() as tmpdir: + agents_dir = Path(tmpdir) + + # Create two agent files + (agents_dir / "agent-a.md").write_text("""--- +name: agent-a +description: Agent A +--- + +System prompt A +""") + (agents_dir / "agent-b.md").write_text("""--- +name: agent-b +description: Agent B +--- + +System prompt B +""") + + registry = AgentRegistry(agents_dir) + agents = registry.scan() + + assert "agent-a" in agents + assert "agent-b" in agents + assert len(agents) == 2 + + # Names list + assert set(registry.agent_names()) == {"agent-a", "agent-b"} + + # Lookup by name + a = registry.get("agent-a") + assert a is not None + assert a.description == "Agent A" + + +def test_agent_registry_ignores_non_md(): + """Non-.md files should be ignored.""" + with tempfile.TemporaryDirectory() as tmpdir: + agents_dir = Path(tmpdir) + (agents_dir / "notes.txt").write_text("not an agent") + (agents_dir / "script.py").write_text("print('hi')") + (agents_dir / "real-agent.md").write_text("""--- +name: real-agent +description: Real +--- + +Prompt +""") + + registry = AgentRegistry(agents_dir) + assert registry.agent_names() == ["real-agent"] diff --git a/tests/test_call_graph.py b/tests/test_call_graph.py new file mode 100644 index 0000000..18fcd2f --- /dev/null +++ b/tests/test_call_graph.py @@ -0,0 +1,523 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for the live call-graph renderable.""" + +from __future__ import annotations + +import time + +import pytest +from rich.console import Console + +from opensensa.interactive.call_graph import CallGraph, _CallNode + + +# --------------------------------------------------------------------------- +# _CallNode basics +# --------------------------------------------------------------------------- + +class TestCallNode: + def test_defaults(self): + node = _CallNode(label="test", kind="tool") + assert node.status == "running" + assert node.end_time is None + assert node.duration_ms is None + assert node.children == [] + + def test_finish_sets_fields(self): + node = _CallNode(label="test", kind="tool") + node.finish(duration_ms=42, result_preview="ok", status="completed") + assert node.status == "completed" + assert node.duration_ms == 42 + assert node.result_preview == "ok" + assert node.end_time is not None + + def test_finish_auto_duration(self): + node = _CallNode(label="test", kind="tool") + time.sleep(0.02) + node.finish() + assert node.duration_ms is not None + assert node.duration_ms >= 15 # at least ~20ms + + +# --------------------------------------------------------------------------- +# CallGraph — event methods +# --------------------------------------------------------------------------- + +class TestCallGraphEvents: + def test_start_end_tool(self): + g = CallGraph(agent_name="test-agent") + assert g.is_empty + + g.start_tool("csv_formatter") + assert not g.is_empty + assert g._tool_calls == 1 + assert len(g._root_nodes) == 1 + assert g._root_nodes[0].status == "running" + + g.end_tool("csv_formatter", duration_ms=100, result="3 rows") + assert g._root_nodes[0].status == "completed" + assert g._root_nodes[0].duration_ms == 100 + assert g._tool_calls == 1 + + def test_start_end_llm(self): + g = CallGraph(agent_name="test-agent") + g.start_llm("test-agent") + assert g._llm_calls == 1 + + g.end_llm("test-agent", duration_ms=500, tokens=120) + assert g._total_tokens == 120 + assert g._root_nodes[0].status == "completed" + + def test_multiple_tools_sequential(self): + g = CallGraph(agent_name="test-agent") + g.start_tool("tool_a") + g.end_tool("tool_a", duration_ms=50) + g.start_tool("tool_b") + g.end_tool("tool_b", duration_ms=60) + + assert len(g._root_nodes) == 2 + assert g._tool_calls == 2 + + def test_delegation_nesting(self): + """Children of a delegation should nest under it.""" + g = CallGraph(agent_name="agent-a") + + g.start_tool("tool_before") + g.end_tool("tool_before", duration_ms=10) + + g.start_delegation("agent-a", "agent-b") + g.start_tool("tool_inside") + g.end_tool("tool_inside", duration_ms=20) + g.end_delegation("agent-b", duration_ms=200) + + g.start_tool("tool_after") + g.end_tool("tool_after", duration_ms=10) + + # Root: tool_before, delegation, tool_after + assert len(g._root_nodes) == 3 + assert g._root_nodes[1].kind == "delegation" + # tool_inside is nested under the delegation + assert len(g._root_nodes[1].children) == 1 + assert g._root_nodes[1].children[0].label == "tool_inside" + assert g._delegations == 1 + + def test_nested_delegations(self): + """A → B → C: tools inside C nest two levels deep.""" + g = CallGraph(agent_name="a") + + g.start_delegation("a", "b") + g.start_delegation("b", "c") + g.start_tool("deep_tool") + g.end_tool("deep_tool", duration_ms=5) + g.end_delegation("c", duration_ms=50) + g.end_delegation("b", duration_ms=100) + + # Root: delegation a→b + assert len(g._root_nodes) == 1 + del_b = g._root_nodes[0] + assert del_b.kind == "delegation" + # Under a→b: delegation b→c + assert len(del_b.children) == 1 + del_c = del_b.children[0] + assert del_c.kind == "delegation" + # Under b→c: deep_tool + assert len(del_c.children) == 1 + assert del_c.children[0].label == "deep_tool" + + def test_agent_start_skips_root_agent(self): + """start_agent for the root agent should not create a node.""" + g = CallGraph(agent_name="main-agent") + g.start_agent("main-agent") + assert g.is_empty # no node for the root agent + + def test_agent_start_shows_sub_agent(self): + g = CallGraph(agent_name="main-agent") + g.start_agent("sub-agent") + assert not g.is_empty + assert g._root_nodes[0].kind == "agent" + + def test_elapsed_ms(self): + g = CallGraph(agent_name="test-agent") + time.sleep(0.02) + assert g.elapsed_ms >= 15 + + +# --------------------------------------------------------------------------- +# CallGraph — delegate tool parsing +# --------------------------------------------------------------------------- + +class TestCallGraphDelegateHandling: + def test_delegate_shows_agent_name(self): + """end_tool for delegate should parse JSON and show agent name.""" + import json + + g = CallGraph(agent_name="orchestrator") + g.start_tool("delegate") + + # start_tool("delegate") should show as delegation, not tool + assert g._root_nodes[0].kind == "delegation" + assert g._delegations == 1 + assert g._tool_calls == 0 # delegate is counted as delegation + + result = json.dumps({ + "status": "success", + "agent": "credit-score-agent", + "result": {}, + }) + g.end_tool("delegate", duration_ms=5000, result=result) + + node = g._root_nodes[0] + assert "credit-score-agent" in node.label + assert node.kind == "delegation" + assert node.status == "completed" + + def test_delegate_shows_sub_agent_tools(self): + """When tools_used is present, they should appear as child nodes.""" + import json + + g = CallGraph(agent_name="orchestrator") + g.start_tool("delegate") + + result = json.dumps({ + "status": "success", + "agent": "data-analyst", + "tools_used": [{"name": "csv_formatter"}, {"name": "run_query"}], + "result": {}, + }) + g.end_tool("delegate", duration_ms=8000, result=result) + + node = g._root_nodes[0] + assert "data-analyst" in node.label + assert len(node.children) == 2 + assert node.children[0].label == "csv_formatter" + assert node.children[0].kind == "tool" + assert node.children[0].status == "completed" + assert node.children[1].label == "run_query" + + def test_delegate_error_shows_failed(self): + """A failed delegation should show error status.""" + import json + + g = CallGraph(agent_name="orchestrator") + g.start_tool("delegate") + + result = json.dumps({ + "status": "error", + "error": "Agent not found", + }) + g.end_tool("delegate", duration_ms=100, result=result) + + node = g._root_nodes[0] + assert node.status == "failed" + + def test_delegate_invalid_json_falls_back(self): + """Non-JSON result should fall back to truncated preview.""" + g = CallGraph(agent_name="orchestrator") + g.start_tool("delegate") + g.end_tool("delegate", duration_ms=100, result="not json") + + node = g._root_nodes[0] + assert node.status == "completed" + assert node.result_preview == "not json" + + def test_delegate_renders_nested_tree(self): + """Delegate with tool children should render as a nested tree.""" + import json + + g = CallGraph(agent_name="main") + g.start_tool("delegate") + result = json.dumps({ + "status": "success", + "agent": "helper", + "tools_used": [{"name": "search"}], + "result": {}, + }) + g.end_tool("delegate", duration_ms=3000, result=result) + + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "helper" in output + assert "search" in output + + def test_nested_delegation_chain(self): + """A → delegate(B) → delegate(C) should render recursively.""" + import json + + g = CallGraph(agent_name="loan-agent") + g.start_tool("delegate") + + # B delegated to C, and C used lookup_history + result = json.dumps({ + "status": "success", + "agent": "credit-score-agent", + "tools_used": [ + { + "name": "delegate", + "agent": "credit-history-agent", + "tools_used": [{"name": "lookup_history"}], + }, + ], + "result": {}, + }) + g.end_tool("delegate", duration_ms=15000, result=result) + + node = g._root_nodes[0] + assert "credit-score-agent" in node.label + # First child: nested delegation to credit-history-agent + assert len(node.children) == 1 + nested = node.children[0] + assert nested.kind == "delegation" + assert "credit-history-agent" in nested.label + # Inside that: lookup_history tool + assert len(nested.children) == 1 + assert nested.children[0].label == "lookup_history" + assert nested.children[0].kind == "tool" + + # Verify rendering includes all levels + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "credit-score-agent" in output + assert "credit-history-agent" in output + assert "lookup_history" in output + + def test_delegate_start_shows_delegating(self): + """start_tool('delegate') should show 'delegating…' not 'delegate'.""" + g = CallGraph(agent_name="main") + g.start_tool("delegate") + + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "delegating" in output + + def test_update_delegation_target(self): + """update_delegation_target should change label of in-progress delegation.""" + g = CallGraph(agent_name="main") + g.start_tool("delegate") + + # Initially shows "delegating…" + node = g._root_nodes[0] + assert "delegating" in node.label + + # After target is known, show the agent name + g.update_delegation_target("credit-score-agent") + assert "credit-score-agent" in node.label + assert node.kind == "delegation" + + def test_update_delegation_target_ignored_when_completed(self): + """update_delegation_target should not change a completed node.""" + import json + + g = CallGraph(agent_name="main") + g.start_tool("delegate") + g.end_tool("delegate", duration_ms=100, result=json.dumps({ + "status": "success", "agent": "agent-a", "result": {}, + })) + # Now try to update — should be ignored (node is completed) + g.update_delegation_target("agent-b") + assert "agent-a" in g._root_nodes[0].label + + def test_add_delegation_sub_event_tool_start(self): + """Real-time tool_start should add a running child to the delegation.""" + g = CallGraph(agent_name="main") + g.start_tool("delegate") + g.update_delegation_target("sub-agent") + + g.add_delegation_sub_event("tool_start", "csv_formatter") + node = g._root_nodes[0] + assert len(node.children) == 1 + assert node.children[0].label == "csv_formatter" + assert node.children[0].kind == "tool" + assert node.children[0].status == "running" + + def test_add_delegation_sub_event_tool_end(self): + """Real-time tool_end should mark the matching child completed.""" + g = CallGraph(agent_name="main") + g.start_tool("delegate") + + g.add_delegation_sub_event("tool_start", "search") + g.add_delegation_sub_event("tool_end", "search") + + child = g._root_nodes[0].children[0] + assert child.status == "completed" + + def test_nested_delegation_via_start_delegation(self): + """Nested delegation B→C should nest under the merged A→B node.""" + g = CallGraph(agent_name="main") + # A's delegate tool starts + g.start_tool("delegate") + # Phase 14: delegation event merges with the tool node + g.start_delegation("main", "agent-b") + + # Only one root node (merged) + assert len(g._root_nodes) == 1 + assert "main → agent-b" in g._root_nodes[0].label + + # Nested delegation B→C (should NOT merge — creates a child) + g.start_delegation("agent-b", "agent-c") + assert len(g._root_nodes) == 1 # still one root + assert len(g._root_nodes[0].children) == 1 + child = g._root_nodes[0].children[0] + assert child.kind == "delegation" + assert "agent-b → agent-c" in child.label + + # End nested delegation + g.end_delegation("agent-c", duration_ms=50) + assert child.status == "completed" + + # End parent delegation + g.end_delegation("agent-b") + + def test_realtime_children_preserved_over_final_tools_used(self): + """Real-time children from event forwarding are kept when they exist. + + The ``tools_used`` from the final result is only used as a fallback + when no real-time children were collected during the delegation. + """ + import json + + g = CallGraph(agent_name="main") + g.start_tool("delegate") + + # Real-time events add children + g.add_delegation_sub_event("tool_start", "tool_a") + g.add_delegation_sub_event("tool_end", "tool_a") + assert len(g._root_nodes[0].children) == 1 + + # Final result has tools_used, but real-time children are preserved + result = json.dumps({ + "status": "success", + "agent": "sub-agent", + "tools_used": [{"name": "tool_a"}, {"name": "tool_b"}], + "result": {}, + }) + g.end_tool("delegate", duration_ms=5000, result=result) + + node = g._root_nodes[0] + # Real-time child kept (tool_b from tools_used is NOT added) + assert len(node.children) == 1 + assert node.children[0].label == "tool_a" + + def test_final_tools_used_fallback_when_no_realtime_children(self): + """tools_used from end_tool result populates children as a fallback.""" + import json + + g = CallGraph(agent_name="main") + g.start_tool("delegate") + + # No real-time events — children list is empty + assert len(g._root_nodes[0].children) == 0 + + result = json.dumps({ + "status": "success", + "agent": "sub-agent", + "tools_used": [{"name": "tool_a"}, {"name": "tool_b"}], + "result": {}, + }) + g.end_tool("delegate", duration_ms=5000, result=result) + + node = g._root_nodes[0] + assert len(node.children) == 2 + assert node.children[0].label == "tool_a" + assert node.children[1].label == "tool_b" + + def test_spinner_animates(self): + """Successive renders should cycle through different spinner frames.""" + from opensensa.interactive.call_graph import SPINNER_FRAMES + + g = CallGraph(agent_name="main") + g.start_tool("running_tool") + + c = Console(width=80, force_terminal=True) + outputs = [] + for _ in range(3): + with c.capture() as capture: + c.print(g) + outputs.append(capture.get()) + + # At least two distinct spinner characters should appear + chars_found = set() + for o in outputs: + for ch in SPINNER_FRAMES: + if ch in o: + chars_found.add(ch) + assert len(chars_found) >= 2, f"Spinner didn't animate: found {chars_found}" + + +# --------------------------------------------------------------------------- +# CallGraph — rendering +# --------------------------------------------------------------------------- + +class TestCallGraphRendering: + def test_renders_without_crash(self): + """Smoke test: rendering to a string should not raise.""" + g = CallGraph(agent_name="test-agent") + g.start_tool("csv_formatter") + g.end_tool("csv_formatter", duration_ms=42, result="ok") + + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "csv_formatter" in output + + def test_renders_empty_graph(self): + """An empty graph should show 'Thinking…'.""" + g = CallGraph(agent_name="test-agent") + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "Thinking" in output + + def test_make_summary_panel(self): + """make_summary should return a Panel with stats.""" + g = CallGraph(agent_name="test-agent") + g.start_tool("tool_a") + g.end_tool("tool_a", duration_ms=50) + g.start_tool("tool_b") + g.end_tool("tool_b", duration_ms=60) + + panel = g.make_summary() + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(panel) + output = capture.get() + assert "tool_a" in output + assert "tool_b" in output + assert "2 tool(s)" in output + + def test_delegation_renders_nested(self): + """Delegation children should be indented in the rendered tree.""" + g = CallGraph(agent_name="a") + g.start_delegation("a", "b") + g.start_tool("nested_tool") + g.end_tool("nested_tool", duration_ms=10) + g.end_delegation("b", duration_ms=100) + + c = Console(width=80, force_terminal=True) + with c.capture() as capture: + c.print(g) + output = capture.get() + assert "a → b" in output + assert "nested_tool" in output diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..41f68e4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,86 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for the CLI commands.""" + +import tempfile +from pathlib import Path + +from click.testing import CliRunner + +from opensensa.cli import cli + + +def test_init_creates_structure(): + """opensensa init should create config, agents/, tools/.""" + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + target = Path(tmpdir) / "my-project" + result = runner.invoke(cli, ["init", str(target)]) + assert result.exit_code == 0, result.output + + assert (target / "opensensa.yaml").exists() + assert (target / "agents").is_dir() + assert (target / "tools").is_dir() + assert (target / ".env").exists() + + # Agent Creator should be copied + assert (target / "agents" / "agent-manager.md").exists() + + +def test_init_idempotent(): + """Running init twice should not overwrite existing files.""" + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + result1 = runner.invoke(cli, ["init", tmpdir]) + assert result1.exit_code == 0 + + # Modify a file + config = Path(tmpdir) / "opensensa.yaml" + original = config.read_text() + config.write_text(original + "\n# custom comment\n") + + # Re-run init + result2 = runner.invoke(cli, ["init", tmpdir]) + assert result2.exit_code == 0 + assert "skip" in result2.output + + # File should be unchanged + assert "# custom comment" in config.read_text() + + +def test_list_agents_empty(): + """list-agents with no agents dir should handle gracefully.""" + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + config = Path(tmpdir) / "opensensa.yaml" + config.write_text("agents:\n directory: ./nonexistent/\n") + result = runner.invoke(cli, ["list-agents", "--config", str(config)]) + assert result.exit_code == 0 + assert "No agents found" in result.output + + +def test_test_command(): + """opensensa test should complete successfully with a valid project.""" + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + # Init first + runner.invoke(cli, ["init", tmpdir]) + + # Run test from project dir + result = runner.invoke(cli, ["test", "--config", str(Path(tmpdir) / "opensensa.yaml")]) + assert result.exit_code == 0 + assert "Smoke test passed" in result.output diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a098c23 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,102 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for config loading and validation.""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from opensensa.config import OpenSensaConfig, load_config, resolve_model + + +def test_default_config(): + """Loading with no file should return valid defaults.""" + with tempfile.TemporaryDirectory() as tmpdir: + config = load_config(project_dir=tmpdir) + assert isinstance(config, OpenSensaConfig) + assert config.server.mcp_port == 8001 + assert config.server.orchestrator_port == 8000 + + +def test_load_config_from_yaml(): + """Load a YAML config file.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_yaml = Path(tmpdir) / "opensensa.yaml" + config_yaml.write_text(""" +models: + default: test-model + registry: + test-model: + base_url: http://localhost:1234/v1 + api_key: test-key + model_name: test-gpt +server: + host: 127.0.0.1 + orchestrator_port: 9999 + mcp_port: 9998 +""") + config = load_config(config_path=config_yaml) + assert config.models.default == "test-model" + assert config.server.orchestrator_port == 9999 + assert config.models.registry["test-model"].model_name == "test-gpt" + + +def test_env_var_interpolation(): + """Environment variables in ${VAR} syntax should be resolved.""" + os.environ["_OPENSENSA_TEST_KEY"] = "my-secret-key" + try: + with tempfile.TemporaryDirectory() as tmpdir: + config_yaml = Path(tmpdir) / "opensensa.yaml" + config_yaml.write_text(""" +models: + default: m + registry: + m: + base_url: http://localhost/v1 + api_key: ${_OPENSENSA_TEST_KEY} + model_name: gpt-test +""") + config = load_config(config_path=config_yaml) + assert config.models.registry["m"].api_key == "my-secret-key" + finally: + del os.environ["_OPENSENSA_TEST_KEY"] + + +def test_resolve_model(): + """resolve_model should look up models by name and handle 'default'.""" + config = OpenSensaConfig.model_validate({ + "models": { + "default": "my-model", + "registry": { + "my-model": { + "base_url": "http://localhost/v1", + "api_key": "key", + "model_name": "gpt-4", + } + } + } + }) + m = resolve_model(config, "default") + assert m.model_name == "gpt-4" + + m2 = resolve_model(config, "my-model") + assert m2.model_name == "gpt-4" + + with pytest.raises(ValueError, match="not found"): + resolve_model(config, "nonexistent") diff --git a/tests/test_framework_tools.py b/tests/test_framework_tools.py new file mode 100644 index 0000000..48d8cf2 --- /dev/null +++ b/tests/test_framework_tools.py @@ -0,0 +1,486 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for framework-provided MCP tools. + +Tests: create_agent, edit_agent, delete_agent, delegate, discover_agents, send_to_agent, list_tools. +""" + +import re +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from opensensa.config import AppConfig, ModelsConfig, ModelConfig, ServerConfig, AgentsConfig, ToolsConfig, LoggingConfig +from opensensa.orchestrator.agent_registry import AgentRegistry + + +# --------------------------------------------------------------------------- +# Helpers — lightweight MCP stub for registering tools +# --------------------------------------------------------------------------- + +class FakeMCP: + """Minimal stub that captures tool registrations.""" + + def __init__(self): + self._tools: dict[str, callable] = {} + + def tool(self, *args, **kwargs): + def decorator(func): + self._tools[func.__name__] = func + return func + return decorator + + def get_tool(self, name: str): + return self._tools[name] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def fake_mcp(): + return FakeMCP() + + +@pytest.fixture +def tmp_agents_dir(): + with tempfile.TemporaryDirectory() as d: + agents_dir = Path(d) / "agents" + agents_dir.mkdir() + (agents_dir / "existing-agent.md").write_text( + '---\n' + 'name: existing-agent\n' + 'description: An existing test agent\n' + 'model: test-model\n' + 'tools: []\n' + '---\n' + '\n' + 'You are an existing test agent.\n' + ) + yield agents_dir + + +@pytest.fixture +def registry(tmp_agents_dir): + reg = AgentRegistry(tmp_agents_dir) + reg.scan() + return reg + + +# --------------------------------------------------------------------------- +# create_agent tests +# --------------------------------------------------------------------------- + +class TestCreateAgent: + def test_create_basic_agent(self, fake_mcp, tmp_agents_dir): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=str(tmp_agents_dir)) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="new-agent", + description="A brand new agent", + system_prompt="You are a helpful assistant.", + ) + + assert result["status"] == "created" + assert result["name"] == "new-agent" + + # Verify file exists and has correct content + file_path = tmp_agents_dir / "new-agent.md" + assert file_path.exists() + content = file_path.read_text() + assert "name: new-agent" in content + assert "description: A brand new agent" in content + assert "You are a helpful assistant." in content + + def test_create_agent_with_tools(self, fake_mcp, tmp_agents_dir): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=str(tmp_agents_dir)) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="tool-agent", + description="Agent with tools", + system_prompt="You use tools.", + tools=["csv_formatter", "add_numbers"], + ) + + assert result["status"] == "created" + content = (tmp_agents_dir / "tool-agent.md").read_text() + assert "csv_formatter" in content + assert "add_numbers" in content + + def test_create_agent_duplicate_rejected(self, fake_mcp, tmp_agents_dir): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=str(tmp_agents_dir)) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="existing-agent", + description="Should fail", + system_prompt="Nope", + ) + + assert "error" in result + assert "already exists" in result["error"] + + def test_create_agent_sanitizes_bad_name(self, fake_mcp, tmp_agents_dir): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=str(tmp_agents_dir)) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="My Cool Agent!", + description="Sanitized name", + system_prompt="Hello.", + ) + + assert result["status"] == "created" + # Name should be sanitized to kebab-case + assert re.match(r"^[a-z0-9-]+$", result["name"]) + + def test_create_agent_rejects_very_short_name(self, fake_mcp, tmp_agents_dir): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=str(tmp_agents_dir)) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="a", + description="Too short", + system_prompt="Fail.", + ) + + assert "error" in result + + def test_create_agent_no_directory_configured(self, fake_mcp): + from opensensa.framework_tools.create_agent import register + register(fake_mcp, agents_directory=None) + + create_fn = fake_mcp.get_tool("create_agent") + result = create_fn( + name="test", + description="Should fail", + system_prompt="No dir", + ) + + assert "error" in result + assert "not configured" in result["error"] + + +# --------------------------------------------------------------------------- +# discover_agents tests +# --------------------------------------------------------------------------- + +class TestDiscoverAgents: + @pytest.mark.asyncio + async def test_discover_local_agents(self, fake_mcp, registry): + from opensensa.framework_tools.discover_agents import register + register( + fake_mcp, + agent_registry=registry, + remote_agents=None, + local_base_url="http://localhost:8000", + ) + + discover_fn = fake_mcp.get_tool("discover_agents") + agents = await discover_fn() + + assert len(agents) >= 1 + local = [a for a in agents if a["source"] == "local"] + assert len(local) >= 1 + assert local[0]["name"] == "existing-agent" + assert local[0]["url"] == "http://localhost:8000/agents/existing-agent" + + @pytest.mark.asyncio + async def test_discover_no_agents(self, fake_mcp): + from opensensa.framework_tools.discover_agents import register + register(fake_mcp, agent_registry=None, remote_agents=None) + + discover_fn = fake_mcp.get_tool("discover_agents") + agents = await discover_fn() + assert agents == [] + + @pytest.mark.asyncio + async def test_discover_remote_agent_failure(self, fake_mcp, registry): + """Remote agent that can't be reached should still return an entry.""" + from opensensa.framework_tools.discover_agents import register + register( + fake_mcp, + agent_registry=registry, + remote_agents=[{"url": "http://unreachable.local:9999"}], + local_base_url="http://localhost:8000", + ) + + discover_fn = fake_mcp.get_tool("discover_agents") + agents = await discover_fn() + + remote = [a for a in agents if a["source"] == "remote"] + assert len(remote) == 1 + assert "unreachable" in remote[0]["name"] + + +# --------------------------------------------------------------------------- +# send_to_agent tests +# --------------------------------------------------------------------------- + +class TestSendToAgent: + @pytest.mark.asyncio + async def test_depth_limit_enforced(self, fake_mcp): + from opensensa.framework_tools.send_to_agent import register, MAX_A2A_DEPTH + register(fake_mcp) + + send_fn = fake_mcp.get_tool("send_to_agent") + result = await send_fn( + agent_url="http://localhost:9000", + message="Hello", + current_depth=MAX_A2A_DEPTH, + ) + + assert result["status"] == "error" + assert "depth limit" in result["error"].lower() + + @pytest.mark.asyncio + async def test_depth_below_limit_proceeds(self, fake_mcp): + """Depth below limit should attempt the request (will fail on connection).""" + from opensensa.framework_tools.send_to_agent import register + register(fake_mcp) + + send_fn = fake_mcp.get_tool("send_to_agent") + result = await send_fn( + agent_url="http://127.0.0.1:1", # unreachable + message="Hello", + current_depth=0, + ) + + # Should get a connection error, not a depth error + assert result["status"] == "error" + assert "depth limit" not in result.get("error", "").lower() + + @pytest.mark.asyncio + async def test_timeout_handling(self, fake_mcp): + """Timeout should return a clean error.""" + from opensensa.framework_tools.send_to_agent import register + register(fake_mcp) + + send_fn = fake_mcp.get_tool("send_to_agent") + # Use unreachable address — will either timeout or connection error + result = await send_fn( + agent_url="http://192.0.2.1", # TEST-NET, guaranteed unroutable + message="Hello", + ) + + assert result["status"] == "error" + + +# --------------------------------------------------------------------------- +# list_tools tests +# --------------------------------------------------------------------------- + +class TestListTools: + def test_list_tools_with_logged_mcp(self): + """list_tools should return registered tool metadata from LoggedMCP.""" + from opensensa.utils.logging import LoggedMCP + from mcp.server.fastmcp import FastMCP + from opensensa.framework_tools.list_tools import register + + raw_mcp = FastMCP("test", host="127.0.0.1", port=18001) + mcp = LoggedMCP(raw_mcp, enable_logging=False) + + # Register a dummy tool + @mcp.tool(title="Dummy Tool", description="A test tool", tags=["test"]) + def dummy_tool(x: str) -> str: + return x + + # Register list_tools + register(mcp) + + # Verify registered_tools tracks both tools + tools = mcp.registered_tools + assert len(tools) >= 2 # Dummy Tool + List Tools + assert "Dummy Tool" in tools + assert tools["Dummy Tool"]["function"] == "dummy_tool" + assert tools["Dummy Tool"]["description"] == "A test tool" + + def test_list_tools_with_fake_mcp(self, fake_mcp): + """list_tools with a basic MCP should return even if introspection limited.""" + from opensensa.framework_tools.list_tools import register + register(fake_mcp) + + list_fn = fake_mcp.get_tool("list_tools") + result = list_fn() + + assert isinstance(result, list) + assert len(result) >= 1 + + +# --------------------------------------------------------------------------- +# delegate tests +# --------------------------------------------------------------------------- + +class TestDelegate: + """Tests for the native delegate FunctionTool (A2A, not MCP).""" + + def _make_agent_def(self, sub_agents=None): + """Create a minimal AgentDefinition-like object for testing.""" + from opensensa.orchestrator.agent_registry import AgentDefinition + return AgentDefinition( + name="caller-agent", + description="test", + system_prompt="test", + model="test-model", + tools=[], + sub_agents=sub_agents or [], + source_file=None, + ) + + @pytest.mark.asyncio + async def test_delegate_depth_limit(self, registry): + from opensensa.framework_tools.delegate import _delegate_impl, MAX_DELEGATION_DEPTH + + result_json = await _delegate_impl( + agent_name="existing-agent", + message="Hello", + allowed_sub_agents=["existing-agent"], + agent_registry=registry, + local_base_url="http://localhost:8000", + current_depth=MAX_DELEGATION_DEPTH, + ) + + import json + result = json.loads(result_json) + assert result["status"] == "error" + assert "depth limit" in result["error"].lower() + + @pytest.mark.asyncio + async def test_delegate_unknown_agent(self, registry): + from opensensa.framework_tools.delegate import _delegate_impl + + result_json = await _delegate_impl( + agent_name="nonexistent-agent", + message="Hello", + allowed_sub_agents=["nonexistent-agent"], + agent_registry=registry, + local_base_url="http://localhost:8000", + ) + + import json + result = json.loads(result_json) + assert result["status"] == "error" + assert "not found" in result["error"].lower() + + @pytest.mark.asyncio + async def test_delegate_rejects_unlisted_agent(self, registry): + """Delegation to an agent not in sub_agents should be rejected.""" + from opensensa.framework_tools.delegate import _delegate_impl + + result_json = await _delegate_impl( + agent_name="existing-agent", + message="Hello", + allowed_sub_agents=["some-other-agent"], + agent_registry=registry, + local_base_url="http://localhost:8000", + ) + + import json + result = json.loads(result_json) + assert result["status"] == "error" + assert "not in your sub_agents" in result["error"] + + @pytest.mark.asyncio + async def test_delegate_resolves_local_agent(self, registry): + """Delegation to a known local agent should attempt the A2A request (will fail on connection).""" + from opensensa.framework_tools.delegate import _delegate_impl + + result_json = await _delegate_impl( + agent_name="existing-agent", + message="Hello", + allowed_sub_agents=["existing-agent"], + agent_registry=registry, + local_base_url="http://127.0.0.1:1", + current_depth=0, + ) + + import json + result = json.loads(result_json) + # Should get a connection error, not a "not found" error + assert result["status"] == "error" + assert "not found" not in result["error"].lower() + + def test_build_delegate_tool_returns_function_tool(self): + """build_delegate_tool should return a FunctionTool with correct metadata.""" + from opensensa.framework_tools.delegate import build_delegate_tool + from agents import FunctionTool + + agent_def = self._make_agent_def(sub_agents=["analyst", "writer"]) + tool = build_delegate_tool(agent_def) + + assert isinstance(tool, FunctionTool) + assert tool.name == "delegate" + assert "analyst" in tool.description + assert "writer" in tool.description + + +# --------------------------------------------------------------------------- +# sub_agents in AgentDefinition tests +# --------------------------------------------------------------------------- + +class TestSubAgentsParsing: + def test_sub_agents_parsed_from_frontmatter(self): + """sub_agents field should be parsed from agent .md frontmatter.""" + from opensensa.orchestrator.agent_registry import AgentDefinition + + with tempfile.TemporaryDirectory() as d: + agent_file = Path(d) / "test-agent.md" + agent_file.write_text( + '---\n' + 'name: test-agent\n' + 'description: Test agent with sub-agents\n' + 'model: test-model\n' + 'tools:\n' + ' - csv_formatter\n' + 'sub_agents:\n' + ' - data-analyst\n' + ' - statistician\n' + '---\n' + '\n' + 'You are a test agent.\n' + ) + + defn = AgentDefinition.from_file(agent_file) + assert defn.sub_agents == ["data-analyst", "statistician"] + + def test_sub_agents_defaults_to_empty(self): + """Agents without sub_agents should have an empty list.""" + from opensensa.orchestrator.agent_registry import AgentDefinition + + with tempfile.TemporaryDirectory() as d: + agent_file = Path(d) / "simple.md" + agent_file.write_text( + '---\n' + 'name: simple\n' + 'description: Simple agent\n' + '---\n' + '\n' + 'You are simple.\n' + ) + + defn = AgentDefinition.from_file(agent_file) + assert defn.sub_agents == [] diff --git a/tests/test_tool_loader.py b/tests/test_tool_loader.py new file mode 100644 index 0000000..4945f6e --- /dev/null +++ b/tests/test_tool_loader.py @@ -0,0 +1,132 @@ +# =========================================================================== +# Copyright (C) 2025 CapsicoHealth Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========================================================================== + +"""Tests for MCP tool auto-discovery from the tools directory.""" + +import tempfile +from pathlib import Path + +import pytest + +from opensensa.mcp_server.tool_loader import discover_and_load_tools + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_tools_dir(): + """Create a temp directory with sample tool files.""" + with tempfile.TemporaryDirectory() as d: + tools_dir = Path(d) / "tools" + tools_dir.mkdir() + yield tools_dir + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestToolLoader: + def test_discover_empty_directory(self, tmp_tools_dir): + """Empty tools directory should load zero modules.""" + loaded = discover_and_load_tools(tmp_tools_dir) + assert loaded == [] + + def test_discover_ignores_dunder_files(self, tmp_tools_dir): + """Files starting with _ should be skipped.""" + (tmp_tools_dir / "__init__.py").write_text("# init\n") + (tmp_tools_dir / "_private.py").write_text("# private\n") + + loaded = discover_and_load_tools(tmp_tools_dir) + assert loaded == [] + + def test_discover_loads_simple_module(self, tmp_tools_dir): + """A plain .py file should be importable.""" + (tmp_tools_dir / "simple_tool.py").write_text( + "LOADED = True\n" + ) + + loaded = discover_and_load_tools(tmp_tools_dir) + assert "simple_tool" in loaded + + def test_discover_calls_register_function(self, tmp_tools_dir): + """If module has register(mcp), it should be called with the MCP instance.""" + (tmp_tools_dir / "reg_tool.py").write_text( + "registered_with = None\n" + "\n" + "def register(mcp):\n" + " global registered_with\n" + " registered_with = mcp\n" + ) + + sentinel = object() + loaded = discover_and_load_tools(tmp_tools_dir, mcp_instance=sentinel) + + assert "reg_tool" in loaded + + # Verify register() was called with our MCP instance + import sys + mod = sys.modules.get("user_tools.reg_tool") + assert mod is not None + assert mod.registered_with is sentinel + + def test_discover_skips_register_without_mcp(self, tmp_tools_dir): + """If no mcp_instance, register() should NOT be called (no crash).""" + (tmp_tools_dir / "reg_tool2.py").write_text( + "call_count = 0\n" + "\n" + "def register(mcp):\n" + " global call_count\n" + " call_count += 1\n" + ) + + loaded = discover_and_load_tools(tmp_tools_dir, mcp_instance=None) + assert "reg_tool2" in loaded + + import sys + mod = sys.modules.get("user_tools.reg_tool2") + assert mod.call_count == 0 + + def test_discover_handles_import_error(self, tmp_tools_dir): + """A broken tool file should not crash the loader.""" + (tmp_tools_dir / "broken_tool.py").write_text( + "import nonexistent_module_12345\n" + ) + (tmp_tools_dir / "good_tool.py").write_text( + "LOADED = True\n" + ) + + loaded = discover_and_load_tools(tmp_tools_dir) + + # good_tool should still load; broken_tool should be skipped + assert "good_tool" in loaded + assert "broken_tool" not in loaded + + def test_discover_nonexistent_directory(self): + """Nonexistent directory should return empty list, not crash.""" + loaded = discover_and_load_tools("/tmp/nonexistent_tools_dir_12345") + assert loaded == [] + + def test_discover_multiple_tools_sorted(self, tmp_tools_dir): + """Multiple tools should be loaded in alphabetical order.""" + (tmp_tools_dir / "z_last.py").write_text("ORDER = 'z'\n") + (tmp_tools_dir / "a_first.py").write_text("ORDER = 'a'\n") + (tmp_tools_dir / "m_middle.py").write_text("ORDER = 'm'\n") + + loaded = discover_and_load_tools(tmp_tools_dir) + assert loaded == ["a_first", "m_middle", "z_last"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bfbf0c7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2231 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "a2a-sdk" +version = "0.3.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/83/3c99b276d09656cce039464509f05bf385e5600d6dc046a131bbcf686930/a2a_sdk-0.3.25.tar.gz", hash = "sha256:afda85bab8d6af0c5d15e82f326c94190f6be8a901ce562d045a338b7127242f", size = 270638, upload-time = "2026-03-10T13:08:46.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f9/6a62520b7ecb945188a6e1192275f4732ff9341cd4629bc975a6c146aeab/a2a_sdk-0.3.25-py3-none-any.whl", hash = "sha256:2fce38faea82eb0b6f9f9c2bcf761b0d78612c80ef0e599b50d566db1b2654b5", size = 149609, upload-time = "2026-03-10T13:08:44.7Z" }, +] + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "7.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836, upload-time = "2025-12-26T15:22:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695, upload-time = "2025-12-26T15:22:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457, upload-time = "2026-03-04T22:08:16.024Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705, upload-time = "2026-03-04T22:08:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, +] + +[[package]] +name = "capsico" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "a2a-sdk" }, + { name = "click" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "httpx" }, + { name = "mcp", extra = ["cli"] }, + { name = "openai" }, + { name = "openai-agents" }, + { name = "prompt-toolkit" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-frontmatter" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uuid7" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "a2a-sdk", specifier = ">=0.3.0" }, + { name = "click", specifier = ">=8.1.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "fastmcp", specifier = ">=2.13.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.14.1" }, + { name = "openai", specifier = ">=1.0.0" }, + { name = "openai-agents", specifier = ">=0.0.7" }, + { name = "prompt-toolkit", specifier = ">=3.0.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-frontmatter", specifier = ">=1.1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "uuid7", specifier = ">=0.1.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e7/3e26855c046ac527cf94d890f6698e703980337f22ea7097e02b35b910f9/cyclopts-4.10.0.tar.gz", hash = "sha256:0ae04a53274e200ef3477c8b54de63b019bc6cd0162d75c718bf40c9c3fb5268", size = 166394, upload-time = "2026-03-14T14:09:31.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/06/d68a5d5d292c2ad2bc6a02e5ca2cb1bb9c15e941ab02f004a06a342d7f0f/cyclopts-4.10.0-py3-none-any.whl", hash = "sha256:50f333382a60df8d40ec14aa2e627316b361c4f478598ada1f4169d959bf9ea7", size = 204097, upload-time = "2026-03-14T14:09:32.504Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "fastmcp" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d5/4e96c44f6c1ea3d812cf5391d81a4f5abaa540abf8d04ecd7f66e0ed11df/jsonschema_path-0.4.5-py3-none-any.whl", hash = "sha256:7d77a2c3f3ec569a40efe5c5f942c44c1af2a6f96fe0866794c9ef5b8f87fd65", size = 19368, upload-time = "2026-03-03T09:56:45.39Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "openai" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/87/eb0abb4ef88ddb95b3c13149384c4c288f584f3be17d6a4f63f8c3e3c226/openai-2.28.0.tar.gz", hash = "sha256:bb7fdff384d2a787fa82e8822d1dd3c02e8cf901d60f1df523b7da03cbb6d48d", size = 670334, upload-time = "2026-03-13T19:56:27.306Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/df122348638885526e53140e9c6b0d844af7312682b3bde9587eebc28b47/openai-2.28.0-py3-none-any.whl", hash = "sha256:79aa5c45dba7fef84085701c235cf13ba88485e1ef4f8dfcedc44fc2a698fc1d", size = 1141218, upload-time = "2026-03-13T19:56:25.46Z" }, +] + +[[package]] +name = "openai-agents" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mcp" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/fe/f831651e2e081fd93565383e799ab9f3dcca2f5b8e8ff545a27a314f8be8/openai_agents-0.12.2.tar.gz", hash = "sha256:d1746d8c35b6210174d739686fd4f375c2da7982a6938e80e8c146cfb96a342f", size = 2620203, upload-time = "2026-03-14T01:19:12.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/49/285ea5d15bac82a03eae1167ae69d871edfe08ed596d18dbb73bef018137/openai_agents-0.12.2-py3-none-any.whl", hash = "sha256:56f760fabe1062d87124746748ff51f7e2ed3a843eb3938224a499e95dd60344", size = 449016, upload-time = "2026-03-14T01:19:10.4Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid7" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052, upload-time = "2021-12-29T01:38:21.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477, upload-time = "2021-12-29T01:38:20.418Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From aab0a278f7a29cc393cc33f278bc1aabd4b87927 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 08:53:03 -0400 Subject: [PATCH 02/13] License fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16330f3..cbf65a1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

PyPI Python 3.10+ - MIT License + Apache 2.0 License

--- From 652af575ffbc54386ecfb485e40cf3a891b3e859 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 09:01:40 -0400 Subject: [PATCH 03/13] readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cbf65a1..ed810b2 100644 --- a/README.md +++ b/README.md @@ -449,4 +449,4 @@ Contributions are welcome! Please open an issue to discuss what you'd like to ch ## License -[Apache 2.0](LICENSE) — © 2025 OpenSensa Team +[Apache 2.0](LICENSE) — Copyright © 2025 CapsicoHealth Inc. From 881d51236df75e8ed3846b9205cf53a5d3512de8 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 10:46:57 -0400 Subject: [PATCH 04/13] fixes --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index bfbf0c7..96dbf47 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ ] [[package]] -name = "capsico" +name = "opensensa" version = "0.1.0" source = { editable = "." } dependencies = [ From d7992ccc7e714d6e9f78514fbd2a69ba0037e372 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 11:27:41 -0400 Subject: [PATCH 05/13] README updates: install flow, tool writing and privacy/security/cost --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ed810b2..9540340 100644 --- a/README.md +++ b/README.md @@ -43,28 +43,18 @@ Most agent frameworks require you to learn complex SDKs, write pages of boilerpl ### Installation ```bash -pip install opensensa +mkdir my-project && cd my-project +uv init +uv add opensensa +opensensa init . ``` -Or install from source: - -```bash -git clone https://github.com/opensensa/opensensa.git -cd opensensa -pip install -e . -``` - -### Create a Project - -```bash -opensensa init my-project -cd my-project -``` - -This scaffolds: +This gives you a project with your own `pyproject.toml` and `uv.lock`, plus the OpenSensa scaffold: ``` my-project/ +├── pyproject.toml # Your project — add any dependencies here +├── uv.lock # Locked dependency versions ├── opensensa.yaml # Model endpoints, server ports, directories ├── .env # API keys (gitignored) ├── agents/ @@ -75,6 +65,23 @@ my-project/ └── generate_visualization.py ``` +Need extra packages for your custom tools? Just add them: + +```bash +uv add pandas sqlalchemy httpx +``` + +
+Install from source (for contributors) + +```bash +git clone https://github.com/opensensa/opensensa.git +cd opensensa +uv pip install -e ".[dev]" +``` + +
+ ### Configure Your Model Edit `opensensa.yaml`: @@ -219,6 +226,23 @@ Generate a tool skeleton: opensensa add-tool my_new_tool ``` +### Adding Custom Dependencies to Tools + +If your custom tools need third-party packages (e.g., `pandas`, `requests`, `sqlalchemy`), just add them to your project: + +```bash +uv add pandas sqlalchemy +``` + +Since you set up your project with `uv init` + `uv add opensensa` (see [Installation](#installation)), all dependencies are tracked in your own `pyproject.toml` and `uv.lock` — you never modify OpenSensa's source. + +Always run OpenSensa through your project's environment: + +```bash +uv run opensensa chat +uv run opensensa serve +``` + ## Multi-Agent Delegation Agents delegate to other agents via the A2A protocol. Declare delegation targets in frontmatter: @@ -377,7 +401,7 @@ Run `opensensa serve --web` and open `http://localhost:8000/web`: ```bash git clone https://github.com/opensensa/opensensa.git cd opensensa -pip install -e ".[dev]" +uv pip install -e ".[dev]" ``` ### Running Tests @@ -447,6 +471,35 @@ Contributions are welcome! Please open an issue to discuss what you'd like to ch 4. Run `pytest` and `ruff check src/ tests/` 5. Submit a pull request +## Privacy, Security & Cost + +OpenSensa is a **local-first framework** — it runs on your machine, and you control where your data goes. However, there are important considerations: + +### Data & Privacy + +- **LLM API calls transmit data externally.** When you configure an LLM endpoint (e.g., OpenAI, Groq, Together), all prompts, tool outputs, and agent conversations are sent to that provider. Review your LLM provider's data retention and privacy policies before sending sensitive or regulated data. +- **Local models keep data on your machine.** If you use a local endpoint (Ollama, vLLM, LM Studio), no data leaves your infrastructure. +- **Logs may contain sensitive content.** Structured JSON logs (`logs/opensensa.jsonl`) capture prompts, responses, tool inputs/outputs, and token usage. Treat log files as sensitive and do not commit them to version control. +- **OpenSensa does not collect telemetry.** No usage data, analytics, or crash reports are sent to CapsicoHealth or any third party. + +### Security + +- **API keys are your responsibility.** Store keys in `.env` files (gitignored by default) or environment variables — never hard-code them in `opensensa.yaml` or agent files. +- **Tools execute arbitrary code.** Custom tools in your `tools/` directory run with the same permissions as the OpenSensa process. Only run tools you trust. Review any third-party tool code before adding it to your project. +- **Agent-generated code is not sandboxed.** If an agent or tool generates and executes code, it runs with full local permissions. Exercise caution with tools that perform file system operations, network calls, or shell commands. +- **Network exposure in serve mode.** `opensensa serve` binds to `0.0.0.0` by default, exposing agent endpoints on your network. For local-only use, set `server.host: 127.0.0.1` in `opensensa.yaml`. There is no built-in authentication — do not expose to the public internet without adding your own auth layer (reverse proxy, API gateway, etc.). + +### Cost + +- **LLM API usage incurs costs.** Every agent turn, tool call, and delegation triggers one or more LLM API calls. Multi-agent delegation chains can multiply costs quickly — an agent delegating to 3 sub-agents, each making multiple LLM calls, can consume significantly more tokens than a single-agent setup. +- **Token usage is logged.** Check `logs/opensensa.jsonl` or the live call graph for per-call token counts to monitor usage. +- **You are solely responsible for all costs** incurred through your configured LLM providers. OpenSensa does not manage, limit, or cap API spend. Set spending limits directly with your LLM provider. +- **Local models are free to run** but require appropriate hardware. + +### Disclaimer + +OpenSensa is provided **"as is"** under the [Apache 2.0 License](LICENSE), without warranty of any kind. CapsicoHealth Inc. is not responsible for any costs, data exposure, security incidents, or damages arising from the use of this software. Users are solely responsible for their configuration choices, API key management, data handling practices, and compliance with applicable laws and regulations. See the [LICENSE](LICENSE) file for full terms. + ## License [Apache 2.0](LICENSE) — Copyright © 2025 CapsicoHealth Inc. From b522f5d498a7ed113578e4272d955ceab81411f0 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Thu, 19 Mar 2026 11:30:26 -0400 Subject: [PATCH 06/13] keyword updates --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2283d83..4ff868d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,9 +26,9 @@ readme = "README.md" license = {file = "LICENSE"} requires-python = ">=3.10" authors = [ - { name = "Capsico Team" }, + { name = "CapsicoHealth Inc. Team" }, ] -keywords = ["ai", "agents", "a2a", "mcp", "llm", "cli"] +keywords = ["ai", "agents", "a2a", "mcp", "llm", "cli", "capsico", "capsicohealth", "healthcare"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", From 5111609d916ab0e0cc0387960e0ab7720a08ecd3 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Fri, 20 Mar 2026 08:53:59 -0400 Subject: [PATCH 07/13] architecture mermaid diagram --- architecture.md | 104 ++ src/opensensa/web/static/app.js | 975 ++---------- src/opensensa/web/static/app.js.bak | 969 ++++++++++++ src/opensensa/web/static/css/chat.css | 425 ++++++ .../web/static/css/delegation-tree.css | 260 ++++ src/opensensa/web/static/css/modal.css | 163 ++ src/opensensa/web/static/css/sidebar.css | 231 +++ src/opensensa/web/static/css/tokens.css | 84 + src/opensensa/web/static/css/toolbar.css | 83 + src/opensensa/web/static/css/utilities.css | 8 + .../web/static/modules/agent-modal.js | 225 +++ src/opensensa/web/static/modules/api.js | 81 + .../web/static/modules/chat-panel.js | 481 ++++++ .../web/static/modules/delegation-tree.js | 307 ++++ src/opensensa/web/static/modules/sidebar.js | 251 +++ src/opensensa/web/static/styles.css | 1355 +---------------- src/opensensa/web/static/styles.css.bak | 1354 ++++++++++++++++ 17 files changed, 5205 insertions(+), 2151 deletions(-) create mode 100644 architecture.md create mode 100644 src/opensensa/web/static/app.js.bak create mode 100644 src/opensensa/web/static/css/chat.css create mode 100644 src/opensensa/web/static/css/delegation-tree.css create mode 100644 src/opensensa/web/static/css/modal.css create mode 100644 src/opensensa/web/static/css/sidebar.css create mode 100644 src/opensensa/web/static/css/tokens.css create mode 100644 src/opensensa/web/static/css/toolbar.css create mode 100644 src/opensensa/web/static/css/utilities.css create mode 100644 src/opensensa/web/static/modules/agent-modal.js create mode 100644 src/opensensa/web/static/modules/api.js create mode 100644 src/opensensa/web/static/modules/chat-panel.js create mode 100644 src/opensensa/web/static/modules/delegation-tree.js create mode 100644 src/opensensa/web/static/modules/sidebar.js create mode 100644 src/opensensa/web/static/styles.css.bak diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..98f3f1b --- /dev/null +++ b/architecture.md @@ -0,0 +1,104 @@ +flowchart TD + subgraph Entry["Entry Points"] + CLI["CLI: opensensa chat
cli.py"] + WEB["Web UI: POST /api/chat/.../messages
web/routes.py"] + A2A["A2A: POST /agents/{name}/
orchestrator/server.py"] + end + + subgraph Config["Configuration"] + CFG["load_config()
config.py
Loads opensensa.yaml"] + REG["AgentRegistry
orchestrator/agent_registry.py
Scans agents/*.md → AgentDefinition"] + end + + subgraph Session["Session Layer"] + CS["ChatSession (CLI)
interactive/chat.py
Spawns MCP + A2A servers
Manages conversation history"] + CM["ChatManager (Web)
web/chat_manager.py
Creates sessions, SSE streaming"] + EX["FrameworkAgentExecutor
a2a/executor.py
Bridges JSON-RPC → Runner"] + end + + subgraph Build["Agent Construction"] + BA["build_agent()
orchestrator/agent_builder.py
• Resolves model via models.py
• Connects MCPServerStreamableHttp
• Applies tool filter
• Adds delegate FunctionTool"] + end + + subgraph Running["Execution Loop"] + RUN["Runner.run() / run_streamed()
OpenAI Agents SDK
system_prompt + input → LLM
Loops: LLM → tool calls → LLM
max_turns=25"] + end + + subgraph Tools["Tool Invocation (MCP)"] + MCP["MCP Server (port 8001)
mcp_server/server.py
FastMCP, streamable-http"] + TL["tool_loader.py
discover_and_load_tools()
Scans tools/*.py"] + FT["Framework Tools
framework_tools/
discover_agents, send_to_agent,
create/edit/delete_agent, list_tools"] + UT["User Tools
tools/
add_numbers, csv_formatter,
generate_visualization"] + end + + subgraph Delegation["Agent Delegation (A2A)"] + DT["delegate FunctionTool
framework_tools/delegate.py
Native tool (NOT on MCP)
Checks depth < 5, allowlist"] + A2AC["A2A HTTP Call
POST /agents/{sub_agent}/
JSON-RPC message/stream
X-A2A-Depth header"] + end + + subgraph Observability["Observability"] + CG["CallGraph (CLI)
interactive/call_graph.py
Rich Live tree display"] + WCG["WebCallGraph (Web)
web/call_graph.py
SSE event stream"] + TR["AgentTraceContext
orchestrator/tracing.py
Span tree, token usage"] + HK["RunHooks
_ChatHooks / _WebHooks
Tool/LLM lifecycle events"] + end + + subgraph Output["Response"] + UI["print_agent_response()
interactive/ui.py
Rich markdown panels"] + SSE["SSE Stream
Browser receives events"] + A2AR["A2A Response
JSON-RPC result + artifacts"] + end + + %% Entry → Config + CLI --> CFG + WEB --> CFG + CFG --> REG + + %% Entry → Session + CLI --> CS + WEB --> CM + A2A --> EX + + %% Session → Build + CS --> BA + CM --> BA + EX --> BA + + %% Build → Run + BA --> RUN + + %% Run → Tools (MCP path) + RUN -- "MCP tool call" --> MCP + MCP --> TL + TL --> FT + TL --> UT + + %% Run → Delegation (A2A path) + RUN -- "delegate() call" --> DT + DT --> A2AC + A2AC -- "Recursive: new
FrameworkAgentExecutor
→ build_agent → Runner" --> EX + + %% Observability + CS -.-> CG + CS -.-> TR + CM -.-> WCG + RUN -.-> HK + HK -.-> CG + HK -.-> WCG + HK -.-> TR + + %% Output + RUN -- "CLI final_output" --> UI + RUN -- "Web SSE stream" --> SSE + RUN -- "A2A artifacts" --> A2AR + + %% Styling + style Entry fill:#e1f5fe,stroke:#0288d1 + style Config fill:#fff3e0,stroke:#ef6c00 + style Session fill:#f3e5f5,stroke:#7b1fa2 + style Build fill:#e8f5e9,stroke:#2e7d32 + style Running fill:#fce4ec,stroke:#c62828 + style Tools fill:#fff9c4,stroke:#f9a825 + style Delegation fill:#ffccbc,stroke:#d84315 + style Observability fill:#e0e0e0,stroke:#616161 + style Output fill:#c8e6c9,stroke:#388e3c diff --git a/src/opensensa/web/static/app.js b/src/opensensa/web/static/app.js index b3376c1..8072536 100644 --- a/src/opensensa/web/static/app.js +++ b/src/opensensa/web/static/app.js @@ -15,45 +15,35 @@ * =========================================================================== */ // @ts-check -/* ── OpenSensa · ES6 Module — mount(rootEl, options) ───────── */ +/* ── OpenSensa · ES6 Orchestrator — mount(rootEl, options) ─ */ /** - * @typedef {Object} OpenSensaOptions - * @property {string} [baseUrl=""] — API base URL prefix (e.g. "https://my-server.com") - * @property {string} [storagePrefix="opensensa"] — localStorage key prefix (for multi-instance) - * @property {any} [marked] — marked.js instance (falls back to window.marked) - * @property {any} [hljs] — highlight.js instance (falls back to window.hljs) + * Thin orchestrator that wires self-contained widget modules together. + * Each widget (Sidebar, ChatPanel, DelegationTree, AgentModal) is a + * standalone class that owns its own DOM, state, and lifecycle. + * + * Individual widgets can be extracted and used independently — see each + * module's JSDoc header for standalone usage instructions. */ +import { Sidebar } from "./modules/sidebar.js"; +import { ChatPanel } from "./modules/chat-panel.js"; +import { DelegationTree } from "./modules/delegation-tree.js"; +import { AgentModal } from "./modules/agent-modal.js"; +import { createApi } from "./modules/api.js"; + /** - * @typedef {{ - * name: string, description: string, model: string, - * tools: string[], sub_agents: string[], - * context_headers: string[], - * sidebarEl: HTMLElement|null, - * sessionId: string|null, - * messages: Array<{role:string, content:string}>, - * isSending: boolean, - * activeTools: Map, - * thinkingEl: HTMLElement|null, - * }} AgentState - * - * @typedef {{ - * id: string, from: string, to: string, - * nodeEl: HTMLElement|null, - * messagesEl: HTMLElement|null, - * activityEl: HTMLElement|null, - * response: string, - * thinkingEl: HTMLElement|null, - * activeTools: Map, - * children: DelegationState[], - * }} DelegationState + * @typedef {Object} OpenSensaOptions + * @property {string} [baseUrl=""] — API base URL prefix + * @property {string} [storagePrefix="opensensa"] — localStorage key prefix + * @property {any} [marked] — marked.js instance + * @property {any} [hljs] — highlight.js instance */ /* ════════════════════════════════════════════════════════════ - HTML Template (injected into rootEl) + Layout skeleton (toolbar + 3-column flex container) ════════════════════════════════════════════════════════════ */ -const TEMPLATE = ` +const LAYOUT = `
@@ -80,130 +70,15 @@ const TEMPLATE = `
- - - - -
-
- - - -

Select an agent to start chatting

- Or create a new agent with the button above -
- - -
- - + +
- - - -
- - -`; /* ════════════════════════════════════════════════════════════ @@ -218,527 +93,93 @@ const TEMPLATE = ` * @returns {{ destroy: () => void }} */ export default function mount(rootEl, options = {}) { - const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); + const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); const storagePrefix = options.storagePrefix || "opensensa"; - const marked = options.marked || /** @type {any} */ (window)["marked"]; - const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; + const marked = options.marked || /** @type {any} */ (window)["marked"]; + const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; - // ── Inject HTML ────────────────────────────────────────── + // ── Inject layout skeleton ─────────────────────────────── rootEl.classList.add("root"); - rootEl.innerHTML = TEMPLATE; - - // ── Scoped query helper ────────────────────────────────── - const $ = (/** @type {string} */ s, /** @type {ParentNode} */ p = rootEl) => p.querySelector(s); - const ref = (/** @type {string} */ name) => /** @type {HTMLElement} */($(`[data-ref="${name}"]`)); - - // ── DOM Refs ───────────────────────────────────────────── - const sidebarList = ref("sidebar-list"); - const sidebar = /** @type {HTMLElement} */ ($(".sidebar")); - const emptyState = ref("empty-state"); - const primaryChat = ref("primary-chat"); - const chatHeader = ref("chat-header"); - const chatAgentName = ref("chat-agent-name"); - const chatAgentModel = ref("chat-agent-model"); - const chatMessages = ref("chat-messages"); - const chatMessagesInner = ref("chat-messages-inner"); - const chatActivity = ref("chat-activity"); - const contextHeadersBar = ref("context-headers-bar"); - const chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); - const chatSendBtn = ref("chat-send-btn"); - const treePanelHint = ref("tree-panel-hint"); - const treeLinkArrow = ref("tree-link-arrow"); - const delegationTree = ref("delegation-tree"); - const modalOverlay = ref("modal-overlay"); - const modalTitle = ref("modal-title"); - const agentForm = /** @type {HTMLFormElement} */ (ref("agent-form")); - const afName = /** @type {HTMLInputElement} */ (ref("af-name")); - const afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); - const afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); - const afModel = /** @type {HTMLInputElement} */ (ref("af-model")); - const afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); - const afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); - const afSubmit = ref("af-submit"); - const afDelete = ref("af-delete"); - const afCancel = ref("af-cancel"); - - // ── State ──────────────────────────────────────────────── - /** @type {Map} */ - const agents = new Map(); - /** @type {string|null} */ - let selectedAgent = null; - /** @type {Map} */ - const activeDelegations = new Map(); - /** @type {string|null} */ - let editingAgent = null; - let destroyed = false; + rootEl.innerHTML = LAYOUT; - // ── Helpers ────────────────────────────────────────────── - const api = (/** @type {string} */ path, /** @type {RequestInit} */ opts = {}) => - fetch(baseUrl + path, { headers: { "Content-Type": "application/json" }, ...opts }); + const ref = (/** @type {string} */ n) => + /** @type {HTMLElement} */ (rootEl.querySelector(`[data-ref="${n}"]`)); - marked.setOptions({ - highlight: (/** @type {string} */ code, /** @type {string} */ lang) => { - if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value; - return hljs.highlightAuto(code).value; - }, - breaks: true, - }); + // ── Instantiate widgets ────────────────────────────────── + const api = createApi(baseUrl); + const sidebar = new Sidebar(ref("sidebar-container")); + const chat = new ChatPanel(ref("chat-container"), { baseUrl, storagePrefix, marked, hljs }); + const tree = new DelegationTree(ref("tree-container"), { marked }); + const modal = new AgentModal(rootEl); - // ── Namespaced localStorage ────────────────────────────── - function storageGet(/** @type {string} */ key) { - try { return localStorage.getItem(`${storagePrefix}-${key}`); } catch { return null; } - } - function storageSet(/** @type {string} */ key, /** @type {string} */ val) { - try { localStorage.setItem(`${storagePrefix}-${key}`, val); } catch { } - } + const treeLinkArrow = ref("tree-link-arrow"); + + // Per-agent state (session IDs, message history) stored in orchestrator + /** @type {Map, contextHeaders: string[] }>} */ + const agentState = new Map(); - /* ════════════════════════════════════════════════════════════ - SIDEBAR — Load & Select Agents - ════════════════════════════════════════════════════════════ */ - - function createSidebarEntry(/** @type {{name:string, description:string, model:string}} */ agent) { - const el = document.createElement("div"); - el.className = "sidebar-agent"; - el.dataset.agent = agent.name; - el.innerHTML = ` - - - `; - - el.addEventListener("click", (e) => { - if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent-edit")) return; - selectAgent(agent.name); - }); - - const editBtn = $(".sidebar-agent-edit", el); - editBtn?.addEventListener("click", (e) => { - e.stopPropagation(); - openEditModal(agent.name); - }); - - return el; + // ── Helpers ────────────────────────────────────────────── + function updateTreeArrow() { + treeLinkArrow.classList.toggle("visible", tree.hasActive); } - async function loadAgents() { - if (destroyed) return; + async function loadAndShowAgents() { try { - const res = await api("/api/agents"); - const agentList = await res.json(); - console.log("[opensensa] loadAgents response:", agentList.map((/** @type {any} */ a) => ({ name: a.name, context_headers: a.context_headers }))); - + const agentList = await api.fetchAgents(); if (!Array.isArray(agentList) || agentList.length === 0) { - emptyState.classList.remove("hidden"); - primaryChat.classList.add("hidden"); + chat.showEmpty(); return; } - - const existing = new Set(agents.keys()); - - agentList.forEach((/** @type {any} */ a) => { - if (agents.has(a.name)) { existing.delete(a.name); return; } - - const sidebarEl = createSidebarEntry(a); - sidebarList.appendChild(sidebarEl); - - /** @type {AgentState} */ - const state = { - name: a.name, - description: a.description || "", - model: a.model || "", - tools: a.tools || [], - sub_agents: a.sub_agents || [], - context_headers: a.context_headers || [], - sidebarEl, - sessionId: null, - messages: [], - isSending: false, - activeTools: new Map(), - thinkingEl: null, - }; - agents.set(a.name, state); - }); - - for (const gone of existing) { - const s = agents.get(gone); - if (s?.sidebarEl) s.sidebarEl.remove(); - agents.delete(gone); + sidebar.loadAgents(agentList); + // Store context_headers per agent + for (const a of agentList) { + if (!agentState.has(a.name)) { + agentState.set(a.name, { sessionId: null, messages: [], contextHeaders: a.context_headers || [] }); + } } - - // No agent selected by default — user clicks to choose } catch (err) { console.error("opensensa loadAgents:", err); } } - function selectAgent(/** @type {string} */ name) { - const agent = agents.get(name); - if (!agent) return; - - sidebar.classList.add("collapsed"); - sidebar.classList.remove("expanded"); - - selectedAgent = name; - storageSet("selected", name); - - for (const a of agents.values()) a.sidebarEl?.classList.toggle("active", a.name === name); - - emptyState.classList.add("hidden"); - primaryChat.classList.remove("hidden"); - - primaryChat.classList.remove("panel-enter"); - void primaryChat.offsetWidth; - primaryChat.classList.add("panel-enter"); - - chatAgentName.textContent = name; - chatAgentModel.textContent = agent.model || "default"; - - chatMessagesInner.innerHTML = ""; - for (const msg of agent.messages) appendMessageToEl(chatMessagesInner, msg.role, msg.content); - chatMessages.scrollTop = chatMessages.scrollHeight; - - chatActivity.innerHTML = ""; - delegationTree.innerHTML = ""; - updateTreePanelVisibility(); - - chatInput.placeholder = `Ask ${name}…`; - renderContextHeaders(agent); - chatInput.focus(); - } - - function updateTreePanelVisibility() { - const hasDelegations = delegationTree.children.length > 0; - treeLinkArrow.classList.toggle("visible", hasDelegations); - treePanelHint.style.display = hasDelegations ? "none" : "block"; - } - - /* ════════════════════════════════════════════════════════════ - CHAT — Messages + Sending - ════════════════════════════════════════════════════════════ */ - - function appendMessageToEl(/** @type {HTMLElement} */ container, /** @type {string} */ role, /** @type {string} */ content) { - const div = document.createElement("div"); - if (role === "user") { - div.className = "chat-msg user"; - div.textContent = content; - } else if (role === "agent") { - div.className = "chat-msg agent"; - div.innerHTML = marked.parse(content); - } else if (role === "error") { - div.className = "chat-msg error-msg"; - div.textContent = content; - } else { - div.className = "chat-msg system-msg"; - div.textContent = content; - } - container.appendChild(div); - } - - function addMessage(/** @type {string} */ role, /** @type {string} */ content) { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent) return; - agent.messages.push({ role, content }); - appendMessageToEl(chatMessagesInner, role, content); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function showThinking() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent || agent.thinkingEl) return; - const el = document.createElement("div"); - el.className = "chat-thinking"; - el.innerHTML = ""; - chatMessagesInner.appendChild(el); - agent.thinkingEl = el; - const dot = $(".chat-status-dot", chatHeader); - if (dot) dot.classList.add("thinking"); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function hideThinking() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent) return; - if (agent.thinkingEl) { agent.thinkingEl.remove(); agent.thinkingEl = null; } - const dot = $(".chat-status-dot", chatHeader); - if (dot) dot.classList.remove("thinking"); - } - - async function ensureSession(/** @type {AgentState} */ agent) { - if (agent.sessionId) return agent.sessionId; - const res = await api("/api/chat/sessions", { - method: "POST", body: JSON.stringify({ agent_name: agent.name }), - }); - const data = await res.json(); - agent.sessionId = data.session_id; - return agent.sessionId; - } - - /* ── Context-header inputs (e.g. X-Document-URL for healthbuddy) ── */ - - /** - * Render input fields above the chat input for each declared context_header. - * @param {AgentState} agent - */ - function renderContextHeaders(agent) { - contextHeadersBar.innerHTML = ""; - const keys = agent.context_headers || []; - console.log("[opensensa] renderContextHeaders", agent.name, "keys:", keys); - if (!keys.length) { - contextHeadersBar.classList.add("hidden"); - return; - } - contextHeadersBar.classList.remove("hidden"); - for (const headerName of keys) { - const label = headerName.replace(/^X-/i, "").replace(/-/g, " "); - const wrapper = document.createElement("div"); - wrapper.className = "ctx-header-field"; - wrapper.innerHTML = ``; - const input = document.createElement("input"); - input.type = "text"; - input.className = "ctx-header-input"; - input.placeholder = headerName; - input.dataset.headerName = headerName; - // Restore from localStorage if previously set - const stored = storageGet(`ctx:${agent.name}:${headerName}`); - if (stored) input.value = stored; - input.addEventListener("change", () => { - storageSet(`ctx:${agent.name}:${headerName}`, input.value); - }); - wrapper.appendChild(input); - contextHeadersBar.appendChild(wrapper); - } - } - - /** - * Read current context-header input values and return as a headers object. - * @returns {Record} - */ - function getContextHeaderValues() { - /** @type {Record} */ - const headers = {}; - contextHeadersBar.querySelectorAll("input.ctx-header-input").forEach((/** @type {HTMLInputElement} */ el) => { - const name = el.dataset.headerName; - if (name && el.value.trim()) headers[name] = el.value.trim(); - }); - console.log("[opensensa] getContextHeaderValues →", headers); - return headers; - } - - async function sendMessage() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent || agent.isSending) return; - const text = chatInput.value.trim(); - if (!text) return; - - agent.isSending = true; - chatInput.value = ""; - addMessage("user", text); + // ── Wire sidebar events ────────────────────────────────── + sidebar.on("agentSelected", (/** @type {{name: string, agent: any}} */ { name, agent }) => { + const state = agentState.get(name); + chat.setAgent( + { name, model: agent?.model || "", contextHeaders: state?.contextHeaders || agent?.context_headers || [] }, + state ? { messages: state.messages } : undefined, + ); + tree.clear(); + updateTreeArrow(); + }); + sidebar.on("editRequested", async (/** @type {{name: string}} */ { name }) => { try { - const sid = await ensureSession(agent); - showThinking(); - - // Collect context headers from the UI inputs (e.g. X-Document-URL) - const extraHeaders = getContextHeaderValues(); - const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; - console.log("[opensensa] sendMessage fetch headers:", allHeaders); - - const res = await fetch(baseUrl + `/api/chat/sessions/${sid}/messages`, { - method: "POST", - headers: allHeaders, - body: JSON.stringify({ message: text }), - }); - - const reader = res.body?.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - if (reader) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const raw = line.slice(6); - if (raw === "[DONE]") continue; - try { handleSSEEvent(JSON.parse(raw)); } catch { } - } - } - } - } catch (err) { - hideThinking(); - addMessage("error", "Network error: " + /** @type {Error} */ (err).message); - } - - agent.isSending = false; - hideThinking(); - clearToolChips(); - } - - /* ════════════════════════════════════════════════════════════ - TOOL CHIPS - ════════════════════════════════════════════════════════════ */ - - function showToolChip(/** @type {string} */ nodeId, /** @type {string} */ toolName, /** @type {string} */ status, /** @type {number|undefined} */ durationMs, /** @type {HTMLElement|null} */ targetEl, /** @type {Map|null} */ targetMap) { - const area = targetEl || chatActivity; - const toolMap = targetMap || agents.get(selectedAgent || "")?.activeTools; - if (!area || !toolMap) return; - - let chip = toolMap.get(nodeId); - if (!chip) { - chip = document.createElement("div"); - chip.className = "tool-chip running"; - chip.innerHTML = `🔧${toolName}`; - area.appendChild(chip); - toolMap.set(nodeId, chip); - } - - if (status === "complete" || status === "completed") { - chip.className = "tool-chip complete"; - const ms = durationMs != null ? ` · ${durationMs}ms` : ""; - chip.innerHTML = `${toolName}${ms}`; - setTimeout(() => { toolMap.delete(nodeId); }, 2000); - } else if (status === "failed") { - chip.className = "tool-chip failed"; - chip.innerHTML = `${toolName}`; - toolMap.delete(nodeId); - } - } - - function clearToolChips() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (agent) agent.activeTools.clear(); - setTimeout(() => { chatActivity.innerHTML = ""; }, 2000); - } - - /* ════════════════════════════════════════════════════════════ - DELEGATION TREE - ════════════════════════════════════════════════════════════ */ - - function createDelegationNode(/** @type {string} */ nodeId, /** @type {string} */ fromAgent, /** @type {string} */ toAgent, /** @type {string} */ message, /** @type {HTMLElement} */ parentContainer) { - const node = document.createElement("div"); - node.className = "delegation-node"; - node.dataset.nodeId = nodeId; - - node.innerHTML = ` - - - - -
-
-
- ${toAgent} - from ${fromAgent} -
-
-
-
`; - - parentContainer.appendChild(node); - - const msgsEl = /** @type {HTMLElement} */ ($(".delegation-card-messages", node)); - const activityEl = /** @type {HTMLElement} */ ($(".delegation-card-activity", node)); - - if (message) { - const label = document.createElement("div"); - label.className = "delegation-from-label"; - label.textContent = `↗ Query from ${fromAgent}`; - msgsEl.appendChild(label); - - const msgDiv = document.createElement("div"); - msgDiv.className = "chat-msg user"; - msgDiv.style.fontSize = "12px"; - msgDiv.style.maxWidth = "95%"; - msgDiv.textContent = message; - msgsEl.appendChild(msgDiv); - } - - const thinkingEl = document.createElement("div"); - thinkingEl.className = "chat-thinking"; - thinkingEl.innerHTML = ""; - msgsEl.appendChild(thinkingEl); - - /** @type {DelegationState} */ - const state = { - id: nodeId, from: fromAgent, to: toAgent, - nodeEl: node, messagesEl: msgsEl, activityEl, - response: "", thinkingEl, activeTools: new Map(), children: [], - }; - activeDelegations.set(nodeId, state); - - const targetAgent = agents.get(toAgent); - if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.add("delegating"); - - updateTreePanelVisibility(); - setTimeout(() => { node.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, 100); - return state; - } - - function endDelegationNode(/** @type {string} */ nodeId) { - const del = activeDelegations.get(nodeId); - if (!del) return; - - if (del.thinkingEl) { del.thinkingEl.remove(); del.thinkingEl = null; } - - if (del.response && del.messagesEl) { - const msgDiv = document.createElement("div"); - msgDiv.className = "chat-msg agent"; - msgDiv.style.fontSize = "12px"; - msgDiv.style.maxWidth = "95%"; - msgDiv.innerHTML = marked.parse(del.response); - del.messagesEl.appendChild(msgDiv); - } - - const card = del.nodeEl ? $(".delegation-card", del.nodeEl) : null; - if (card) { card.classList.remove("active"); card.classList.add("completed"); } - - const arrow = del.nodeEl ? $(".delegation-connector-arrow", del.nodeEl) : null; - if (arrow) { - arrow.innerHTML = ` - - `; - } - - const targetAgent = agents.get(del.to); - if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.remove("delegating"); - - activeDelegations.delete(nodeId); - updateTreePanelVisibility(); - } + const data = await api.fetchAgent(name); + modal.openEdit(data); + } catch { modal.openEdit({ name }); } + }); - function getDelegationChildContainer(/** @type {DelegationState} */ parentDel) { - if (!parentDel.nodeEl) return delegationTree; - let nested = /** @type {HTMLElement|null} */ ($(".delegation-nested", parentDel.nodeEl)); - if (!nested) { - nested = document.createElement("div"); - nested.className = "delegation-nested delegation-tree"; - parentDel.nodeEl.appendChild(nested); - } - return nested; - } + sidebar.on("refreshRequested", async () => { + sidebar.clear(); + chat.showEmpty(); + tree.clear(); + agentState.clear(); + updateTreeArrow(); + await loadAndShowAgents(); + }); - /* ════════════════════════════════════════════════════════════ - SSE EVENT ROUTER - ════════════════════════════════════════════════════════════ */ + // ── Wire chat events ───────────────────────────────────── + chat.on("editRequested", async (/** @type {string} */ name) => { + try { + const data = await api.fetchAgent(name); + modal.openEdit(data); + } catch { modal.openEdit({ name }); } + }); - function findInTree(/** @type {any[]} */ tree, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { - for (const n of tree) { + /** Recursive search for a node in the SSE tree structure. */ + function findInTree(/** @type {any[]} */ nodes, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { + for (const n of nodes) { if (n.id === nodeId) return { node: n, ancestors }; if (n.children?.length) { const r = findInTree(n.children, nodeId, [...ancestors, n]); @@ -748,220 +189,128 @@ export default function mount(rootEl, options = {}) { return null; } - function getDelegationForEvent(/** @type {any[]} */ tree, /** @type {string} */ nodeId) { - if (!nodeId || !tree) return null; - const r = findInTree(tree, nodeId); + /** Find the delegation ancestor for an SSE event node. */ + function getDelegationIdForEvent(/** @type {any[]} */ sseTree, /** @type {string} */ nodeId) { + if (!nodeId || !sseTree) return null; + const r = findInTree(sseTree, nodeId); if (!r) return null; for (const anc of r.ancestors) { - if (anc.kind === "delegation") { - const del = activeDelegations.get(anc.id); - if (del) return del; - } + if (anc.kind === "delegation" && tree.get(anc.id)) return anc.id; } return null; } - function handleSSEEvent(/** @type {any} */ evt) { - const event = evt.event; - switch (event) { + // SSE event routing — delegation and tool events go to the tree widget + chat.on("sseEvent", (/** @type {any} */ evt) => { + switch (evt.event) { case "turn_complete": - hideThinking(); - if (evt.response) addMessage("agent", evt.response); - for (const [nid] of activeDelegations) endDelegationNode(nid); - break; - case "turn_error": - hideThinking(); - addMessage("error", evt.error || "Unknown error"); - for (const [nid] of activeDelegations) endDelegationNode(nid); + tree.endAll(); + updateTreeArrow(); + // Save messages back to orchestrator state + if (chat.agentName) { + const s = agentState.get(chat.agentName); + if (s) s.messages = chat.getState().messages; + } break; case "tool_start": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (del) showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, del.activityEl, del.activeTools); - else showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, null, null); + const delId = getDelegationIdForEvent(evt.tree, evt.node_id); + if (delId) tree.startTool({ delegationId: delId, nodeId: evt.node_id, toolName: evt.tool || "tool" }); + else chat.showToolChip(evt.node_id, evt.tool || "tool", "running"); break; } case "tool_end": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (del) showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, del.activityEl, del.activeTools); - else showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, null, null); + const delId = getDelegationIdForEvent(evt.tree, evt.node_id); + if (delId) tree.endTool({ delegationId: delId, nodeId: evt.node_id, toolName: evt.tool || "tool", durationMs: evt.duration_ms }); + else chat.showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms); break; } - case "delegation_start": { - const fromAgent = evt.from_agent || selectedAgent || ""; - const toAgent = evt.to_agent; - let parentContainer = delegationTree; - for (const del of activeDelegations.values()) { - if (del.to === fromAgent) { parentContainer = getDelegationChildContainer(del); break; } - } - createDelegationNode(evt.node_id, fromAgent, toAgent, evt.message || "", parentContainer); + case "delegation_start": + tree.startDelegation({ + id: evt.node_id, + fromAgent: evt.from_agent || sidebar.selected || "", + toAgent: evt.to_agent, + message: evt.message || "", + }); + updateTreeArrow(); break; - } - case "delegation_end": { - const del = activeDelegations.get(evt.node_id); - if (del) del.response = evt.response || ""; - endDelegationNode(evt.node_id); + case "delegation_end": + tree.endDelegation({ id: evt.node_id, response: evt.response || "" }); + updateTreeArrow(); break; - } case "llm_start": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (!del) showThinking(); + const delId = getDelegationIdForEvent(evt.tree, evt.node_id); + if (!delId) chat.showThinking(); break; } case "llm_end": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (!del) hideThinking(); + const delId = getDelegationIdForEvent(evt.tree, evt.node_id); + if (!delId) chat.hideThinking(); break; } default: break; } - } + }); - /* ════════════════════════════════════════════════════════════ - AGENT CRUD MODAL - ════════════════════════════════════════════════════════════ */ - - function openCreateModal() { - editingAgent = null; - agentForm.reset(); - afName.disabled = false; - afSubmit.textContent = "Create"; - afDelete.classList.add("hidden"); - modalTitle.textContent = "New Agent"; - modalOverlay.classList.remove("hidden"); - } + // ── Wire delegation tree events ────────────────────────── + tree.on("delegationStart", (/** @type {{toAgent: string}} */ { toAgent }) => { + sidebar.highlightDelegating(toAgent, true); + }); - async function openEditModal(/** @type {string} */ name) { - editingAgent = name; - afName.disabled = true; - afSubmit.textContent = "Save"; - afDelete.classList.remove("hidden"); - modalTitle.textContent = `Edit ${name}`; - modalOverlay.classList.remove("hidden"); - try { - const res = await api(`/api/agents/${name}`); - const data = await res.json(); - afName.value = name; - afDesc.value = data.description || ""; - afPrompt.value = data.system_prompt || ""; - afModel.value = data.model || ""; - afTools.value = (data.tools || []).join(", "); - afSubs.value = (data.sub_agents || []).join(", "); - } catch { agentForm.reset(); afName.value = name; } - } + tree.on("delegationEnd", (/** @type {{toAgent: string}} */ { toAgent }) => { + sidebar.highlightDelegating(toAgent, false); + }); - function closeModal() { modalOverlay.classList.add("hidden"); } - - async function submitAgent(/** @type {Event} */ e) { - e.preventDefault(); - const payload = { - name: afName.value.trim(), - description: afDesc.value.trim() || "Agent", - system_prompt: afPrompt.value.trim(), - model: afModel.value.trim() || "${default}", - tools: afTools.value ? afTools.value.split(",").map(s => s.trim()).filter(Boolean) : [], - sub_agents: afSubs.value ? afSubs.value.split(",").map(s => s.trim()).filter(Boolean) : [], - }; + // ── Wire modal events ──────────────────────────────────── + modal.on("submitted", async (/** @type {{payload: any, isEdit: boolean, name: string|null}} */ { payload, isEdit, name }) => { try { - if (editingAgent) await api(`/api/agents/${editingAgent}`, { method: "PUT", body: JSON.stringify(payload) }); - else await api("/api/agents", { method: "POST", body: JSON.stringify(payload) }); - closeModal(); - await reloadAgents(); + if (isEdit && name) await api.updateAgent(name, payload); + else await api.createAgent(payload); + modal.close(); + // Reload agents + sidebar.clear(); + chat.showEmpty(); + tree.clear(); + agentState.clear(); + updateTreeArrow(); + await loadAndShowAgents(); } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } - } + }); - async function deleteCurrentAgent() { - if (!editingAgent || !confirm(`Delete agent "${editingAgent}"?`)) return; + modal.on("deleted", async (/** @type {{name: string}} */ { name }) => { try { - await api(`/api/agents/${editingAgent}`, { method: "DELETE" }); - closeModal(); - const agent = agents.get(editingAgent); - if (agent?.sidebarEl) agent.sidebarEl.remove(); - agents.delete(editingAgent); - if (editingAgent === selectedAgent) { - selectedAgent = null; - if (agents.size > 0) selectAgent(/** @type {string} */(agents.keys().next().value)); - else { emptyState.classList.remove("hidden"); primaryChat.classList.add("hidden"); sidebar.classList.remove("collapsed"); } + await api.deleteAgent(name); + modal.close(); + sidebar.removeAgent(name); + agentState.delete(name); + if (sidebar.selected === name || chat.agentName === name) { + const remaining = sidebar.agentNames; + if (remaining.length > 0) sidebar.selectAgent(remaining[0]); + else { chat.showEmpty(); sidebar.expand(); } } } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } - } + }); - async function reloadAgents() { - for (const a of agents.values()) { if (a.sidebarEl) a.sidebarEl.remove(); } - agents.clear(); - selectedAgent = null; - sidebar.classList.remove("collapsed"); - emptyState.classList.remove("hidden"); - primaryChat.classList.add("hidden"); - activeDelegations.clear(); - delegationTree.innerHTML = ""; - chatMessagesInner.innerHTML = ""; - chatActivity.innerHTML = ""; - updateTreePanelVisibility(); - await loadAgents(); - } + // ── Toolbar "New Agent" button ─────────────────────────── + ref("btn-add-agent").addEventListener("click", () => modal.openCreate()); - /* ════════════════════════════════════════════════════════════ - EVENT BINDINGS (tracked for cleanup) - ════════════════════════════════════════════════════════════ */ - /** @type {Array<[EventTarget, string, EventListener]>} */ - const listeners = []; + // ── Init ───────────────────────────────────────────────── + loadAndShowAgents(); - function on(/** @type {EventTarget} */ el, /** @type {string} */ evt, /** @type {EventListener} */ fn) { - el.addEventListener(evt, fn); - listeners.push([el, evt, fn]); - } - - on(chatInput, "keydown", (e) => { - if (/** @type {KeyboardEvent} */ (e).key === "Enter" && !/** @type {KeyboardEvent} */ (e).shiftKey) { - e.preventDefault(); sendMessage(); - } - }); - on(chatSendBtn, "click", () => sendMessage()); - on(ref("btn-edit-agent"), "click", () => { if (selectedAgent) openEditModal(selectedAgent); }); - on(ref("btn-add-agent"), "click", openCreateModal); - on(ref("btn-refresh-agents"), "click", async () => { - const btn = ref("btn-refresh-agents"); - btn.classList.add("spinning"); - try { await reloadAgents(); } finally { setTimeout(() => btn.classList.remove("spinning"), 400); } - }); - on(sidebar, "click", (e) => { - if (!sidebar.classList.contains("collapsed")) return; - if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent")) return; - sidebar.classList.toggle("expanded"); - }); - on(document, "click", (e) => { - if (!sidebar.classList.contains("expanded")) return; - if (!sidebar.contains(/** @type {Node} */(e.target))) { - sidebar.classList.remove("expanded"); - } - }); - on(ref("modal-close"), "click", closeModal); - on(modalOverlay, "click", (e) => { if (e.target === modalOverlay) closeModal(); }); - on(afCancel, "click", closeModal); - on(agentForm, "submit", submitAgent); - on(afDelete, "click", deleteCurrentAgent); - - /* ════════════════════════════════════════════════════════════ - INIT - ════════════════════════════════════════════════════════════ */ - loadAgents(); - - /* ════════════════════════════════════════════════════════════ - DESTROY — cleanup for unmount - ════════════════════════════════════════════════════════════ */ + // ── Destroy — cleanup for unmount ──────────────────────── return { destroy() { - destroyed = true; - for (const [el, evt, fn] of listeners) el.removeEventListener(evt, fn); - listeners.length = 0; + sidebar.destroy(); + chat.destroy(); + tree.destroy(); + modal.destroy(); rootEl.classList.remove("root"); rootEl.innerHTML = ""; }, diff --git a/src/opensensa/web/static/app.js.bak b/src/opensensa/web/static/app.js.bak new file mode 100644 index 0000000..b3376c1 --- /dev/null +++ b/src/opensensa/web/static/app.js.bak @@ -0,0 +1,969 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================================================================== */ + +// @ts-check +/* ── OpenSensa · ES6 Module — mount(rootEl, options) ───────── */ + +/** + * @typedef {Object} OpenSensaOptions + * @property {string} [baseUrl=""] — API base URL prefix (e.g. "https://my-server.com") + * @property {string} [storagePrefix="opensensa"] — localStorage key prefix (for multi-instance) + * @property {any} [marked] — marked.js instance (falls back to window.marked) + * @property {any} [hljs] — highlight.js instance (falls back to window.hljs) + */ + +/** + * @typedef {{ + * name: string, description: string, model: string, + * tools: string[], sub_agents: string[], + * context_headers: string[], + * sidebarEl: HTMLElement|null, + * sessionId: string|null, + * messages: Array<{role:string, content:string}>, + * isSending: boolean, + * activeTools: Map, + * thinkingEl: HTMLElement|null, + * }} AgentState + * + * @typedef {{ + * id: string, from: string, to: string, + * nodeEl: HTMLElement|null, + * messagesEl: HTMLElement|null, + * activityEl: HTMLElement|null, + * response: string, + * thinkingEl: HTMLElement|null, + * activeTools: Map, + * children: DelegationState[], + * }} DelegationState + */ + +/* ════════════════════════════════════════════════════════════ + HTML Template (injected into rootEl) + ════════════════════════════════════════════════════════════ */ +const TEMPLATE = ` + +
+
+ +
+
+ +
+
+ + +
+ + + + +
+
+ + + +

Select an agent to start chatting

+ Or create a new agent with the button above +
+ + +
+ + + + + + +
+ + +`; + +/* ════════════════════════════════════════════════════════════ + mount() — public entry point + ════════════════════════════════════════════════════════════ */ + +/** + * Mount the OpenSensa Agent Chat UI into a container element. + * + * @param {HTMLElement} rootEl — the container element (any div) + * @param {OpenSensaOptions} [options={}] + * @returns {{ destroy: () => void }} + */ +export default function mount(rootEl, options = {}) { + const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); + const storagePrefix = options.storagePrefix || "opensensa"; + const marked = options.marked || /** @type {any} */ (window)["marked"]; + const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; + + // ── Inject HTML ────────────────────────────────────────── + rootEl.classList.add("root"); + rootEl.innerHTML = TEMPLATE; + + // ── Scoped query helper ────────────────────────────────── + const $ = (/** @type {string} */ s, /** @type {ParentNode} */ p = rootEl) => p.querySelector(s); + const ref = (/** @type {string} */ name) => /** @type {HTMLElement} */($(`[data-ref="${name}"]`)); + + // ── DOM Refs ───────────────────────────────────────────── + const sidebarList = ref("sidebar-list"); + const sidebar = /** @type {HTMLElement} */ ($(".sidebar")); + const emptyState = ref("empty-state"); + const primaryChat = ref("primary-chat"); + const chatHeader = ref("chat-header"); + const chatAgentName = ref("chat-agent-name"); + const chatAgentModel = ref("chat-agent-model"); + const chatMessages = ref("chat-messages"); + const chatMessagesInner = ref("chat-messages-inner"); + const chatActivity = ref("chat-activity"); + const contextHeadersBar = ref("context-headers-bar"); + const chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); + const chatSendBtn = ref("chat-send-btn"); + const treePanelHint = ref("tree-panel-hint"); + const treeLinkArrow = ref("tree-link-arrow"); + const delegationTree = ref("delegation-tree"); + const modalOverlay = ref("modal-overlay"); + const modalTitle = ref("modal-title"); + const agentForm = /** @type {HTMLFormElement} */ (ref("agent-form")); + const afName = /** @type {HTMLInputElement} */ (ref("af-name")); + const afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); + const afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); + const afModel = /** @type {HTMLInputElement} */ (ref("af-model")); + const afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); + const afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); + const afSubmit = ref("af-submit"); + const afDelete = ref("af-delete"); + const afCancel = ref("af-cancel"); + + // ── State ──────────────────────────────────────────────── + /** @type {Map} */ + const agents = new Map(); + /** @type {string|null} */ + let selectedAgent = null; + /** @type {Map} */ + const activeDelegations = new Map(); + /** @type {string|null} */ + let editingAgent = null; + let destroyed = false; + + // ── Helpers ────────────────────────────────────────────── + const api = (/** @type {string} */ path, /** @type {RequestInit} */ opts = {}) => + fetch(baseUrl + path, { headers: { "Content-Type": "application/json" }, ...opts }); + + marked.setOptions({ + highlight: (/** @type {string} */ code, /** @type {string} */ lang) => { + if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value; + return hljs.highlightAuto(code).value; + }, + breaks: true, + }); + + // ── Namespaced localStorage ────────────────────────────── + function storageGet(/** @type {string} */ key) { + try { return localStorage.getItem(`${storagePrefix}-${key}`); } catch { return null; } + } + function storageSet(/** @type {string} */ key, /** @type {string} */ val) { + try { localStorage.setItem(`${storagePrefix}-${key}`, val); } catch { } + } + + /* ════════════════════════════════════════════════════════════ + SIDEBAR — Load & Select Agents + ════════════════════════════════════════════════════════════ */ + + function createSidebarEntry(/** @type {{name:string, description:string, model:string}} */ agent) { + const el = document.createElement("div"); + el.className = "sidebar-agent"; + el.dataset.agent = agent.name; + el.innerHTML = ` + + + `; + + el.addEventListener("click", (e) => { + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent-edit")) return; + selectAgent(agent.name); + }); + + const editBtn = $(".sidebar-agent-edit", el); + editBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + openEditModal(agent.name); + }); + + return el; + } + + async function loadAgents() { + if (destroyed) return; + try { + const res = await api("/api/agents"); + const agentList = await res.json(); + console.log("[opensensa] loadAgents response:", agentList.map((/** @type {any} */ a) => ({ name: a.name, context_headers: a.context_headers }))); + + if (!Array.isArray(agentList) || agentList.length === 0) { + emptyState.classList.remove("hidden"); + primaryChat.classList.add("hidden"); + return; + } + + const existing = new Set(agents.keys()); + + agentList.forEach((/** @type {any} */ a) => { + if (agents.has(a.name)) { existing.delete(a.name); return; } + + const sidebarEl = createSidebarEntry(a); + sidebarList.appendChild(sidebarEl); + + /** @type {AgentState} */ + const state = { + name: a.name, + description: a.description || "", + model: a.model || "", + tools: a.tools || [], + sub_agents: a.sub_agents || [], + context_headers: a.context_headers || [], + sidebarEl, + sessionId: null, + messages: [], + isSending: false, + activeTools: new Map(), + thinkingEl: null, + }; + agents.set(a.name, state); + }); + + for (const gone of existing) { + const s = agents.get(gone); + if (s?.sidebarEl) s.sidebarEl.remove(); + agents.delete(gone); + } + + // No agent selected by default — user clicks to choose + } catch (err) { + console.error("opensensa loadAgents:", err); + } + } + + function selectAgent(/** @type {string} */ name) { + const agent = agents.get(name); + if (!agent) return; + + sidebar.classList.add("collapsed"); + sidebar.classList.remove("expanded"); + + selectedAgent = name; + storageSet("selected", name); + + for (const a of agents.values()) a.sidebarEl?.classList.toggle("active", a.name === name); + + emptyState.classList.add("hidden"); + primaryChat.classList.remove("hidden"); + + primaryChat.classList.remove("panel-enter"); + void primaryChat.offsetWidth; + primaryChat.classList.add("panel-enter"); + + chatAgentName.textContent = name; + chatAgentModel.textContent = agent.model || "default"; + + chatMessagesInner.innerHTML = ""; + for (const msg of agent.messages) appendMessageToEl(chatMessagesInner, msg.role, msg.content); + chatMessages.scrollTop = chatMessages.scrollHeight; + + chatActivity.innerHTML = ""; + delegationTree.innerHTML = ""; + updateTreePanelVisibility(); + + chatInput.placeholder = `Ask ${name}…`; + renderContextHeaders(agent); + chatInput.focus(); + } + + function updateTreePanelVisibility() { + const hasDelegations = delegationTree.children.length > 0; + treeLinkArrow.classList.toggle("visible", hasDelegations); + treePanelHint.style.display = hasDelegations ? "none" : "block"; + } + + /* ════════════════════════════════════════════════════════════ + CHAT — Messages + Sending + ════════════════════════════════════════════════════════════ */ + + function appendMessageToEl(/** @type {HTMLElement} */ container, /** @type {string} */ role, /** @type {string} */ content) { + const div = document.createElement("div"); + if (role === "user") { + div.className = "chat-msg user"; + div.textContent = content; + } else if (role === "agent") { + div.className = "chat-msg agent"; + div.innerHTML = marked.parse(content); + } else if (role === "error") { + div.className = "chat-msg error-msg"; + div.textContent = content; + } else { + div.className = "chat-msg system-msg"; + div.textContent = content; + } + container.appendChild(div); + } + + function addMessage(/** @type {string} */ role, /** @type {string} */ content) { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent) return; + agent.messages.push({ role, content }); + appendMessageToEl(chatMessagesInner, role, content); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + function showThinking() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent || agent.thinkingEl) return; + const el = document.createElement("div"); + el.className = "chat-thinking"; + el.innerHTML = ""; + chatMessagesInner.appendChild(el); + agent.thinkingEl = el; + const dot = $(".chat-status-dot", chatHeader); + if (dot) dot.classList.add("thinking"); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + function hideThinking() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent) return; + if (agent.thinkingEl) { agent.thinkingEl.remove(); agent.thinkingEl = null; } + const dot = $(".chat-status-dot", chatHeader); + if (dot) dot.classList.remove("thinking"); + } + + async function ensureSession(/** @type {AgentState} */ agent) { + if (agent.sessionId) return agent.sessionId; + const res = await api("/api/chat/sessions", { + method: "POST", body: JSON.stringify({ agent_name: agent.name }), + }); + const data = await res.json(); + agent.sessionId = data.session_id; + return agent.sessionId; + } + + /* ── Context-header inputs (e.g. X-Document-URL for healthbuddy) ── */ + + /** + * Render input fields above the chat input for each declared context_header. + * @param {AgentState} agent + */ + function renderContextHeaders(agent) { + contextHeadersBar.innerHTML = ""; + const keys = agent.context_headers || []; + console.log("[opensensa] renderContextHeaders", agent.name, "keys:", keys); + if (!keys.length) { + contextHeadersBar.classList.add("hidden"); + return; + } + contextHeadersBar.classList.remove("hidden"); + for (const headerName of keys) { + const label = headerName.replace(/^X-/i, "").replace(/-/g, " "); + const wrapper = document.createElement("div"); + wrapper.className = "ctx-header-field"; + wrapper.innerHTML = ``; + const input = document.createElement("input"); + input.type = "text"; + input.className = "ctx-header-input"; + input.placeholder = headerName; + input.dataset.headerName = headerName; + // Restore from localStorage if previously set + const stored = storageGet(`ctx:${agent.name}:${headerName}`); + if (stored) input.value = stored; + input.addEventListener("change", () => { + storageSet(`ctx:${agent.name}:${headerName}`, input.value); + }); + wrapper.appendChild(input); + contextHeadersBar.appendChild(wrapper); + } + } + + /** + * Read current context-header input values and return as a headers object. + * @returns {Record} + */ + function getContextHeaderValues() { + /** @type {Record} */ + const headers = {}; + contextHeadersBar.querySelectorAll("input.ctx-header-input").forEach((/** @type {HTMLInputElement} */ el) => { + const name = el.dataset.headerName; + if (name && el.value.trim()) headers[name] = el.value.trim(); + }); + console.log("[opensensa] getContextHeaderValues →", headers); + return headers; + } + + async function sendMessage() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (!agent || agent.isSending) return; + const text = chatInput.value.trim(); + if (!text) return; + + agent.isSending = true; + chatInput.value = ""; + addMessage("user", text); + + try { + const sid = await ensureSession(agent); + showThinking(); + + // Collect context headers from the UI inputs (e.g. X-Document-URL) + const extraHeaders = getContextHeaderValues(); + const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; + console.log("[opensensa] sendMessage fetch headers:", allHeaders); + + const res = await fetch(baseUrl + `/api/chat/sessions/${sid}/messages`, { + method: "POST", + headers: allHeaders, + body: JSON.stringify({ message: text }), + }); + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6); + if (raw === "[DONE]") continue; + try { handleSSEEvent(JSON.parse(raw)); } catch { } + } + } + } + } catch (err) { + hideThinking(); + addMessage("error", "Network error: " + /** @type {Error} */ (err).message); + } + + agent.isSending = false; + hideThinking(); + clearToolChips(); + } + + /* ════════════════════════════════════════════════════════════ + TOOL CHIPS + ════════════════════════════════════════════════════════════ */ + + function showToolChip(/** @type {string} */ nodeId, /** @type {string} */ toolName, /** @type {string} */ status, /** @type {number|undefined} */ durationMs, /** @type {HTMLElement|null} */ targetEl, /** @type {Map|null} */ targetMap) { + const area = targetEl || chatActivity; + const toolMap = targetMap || agents.get(selectedAgent || "")?.activeTools; + if (!area || !toolMap) return; + + let chip = toolMap.get(nodeId); + if (!chip) { + chip = document.createElement("div"); + chip.className = "tool-chip running"; + chip.innerHTML = `🔧${toolName}`; + area.appendChild(chip); + toolMap.set(nodeId, chip); + } + + if (status === "complete" || status === "completed") { + chip.className = "tool-chip complete"; + const ms = durationMs != null ? ` · ${durationMs}ms` : ""; + chip.innerHTML = `${toolName}${ms}`; + setTimeout(() => { toolMap.delete(nodeId); }, 2000); + } else if (status === "failed") { + chip.className = "tool-chip failed"; + chip.innerHTML = `${toolName}`; + toolMap.delete(nodeId); + } + } + + function clearToolChips() { + if (!selectedAgent) return; + const agent = agents.get(selectedAgent); + if (agent) agent.activeTools.clear(); + setTimeout(() => { chatActivity.innerHTML = ""; }, 2000); + } + + /* ════════════════════════════════════════════════════════════ + DELEGATION TREE + ════════════════════════════════════════════════════════════ */ + + function createDelegationNode(/** @type {string} */ nodeId, /** @type {string} */ fromAgent, /** @type {string} */ toAgent, /** @type {string} */ message, /** @type {HTMLElement} */ parentContainer) { + const node = document.createElement("div"); + node.className = "delegation-node"; + node.dataset.nodeId = nodeId; + + node.innerHTML = ` + + + + +
+
+
+ ${toAgent} + from ${fromAgent} +
+
+
+
`; + + parentContainer.appendChild(node); + + const msgsEl = /** @type {HTMLElement} */ ($(".delegation-card-messages", node)); + const activityEl = /** @type {HTMLElement} */ ($(".delegation-card-activity", node)); + + if (message) { + const label = document.createElement("div"); + label.className = "delegation-from-label"; + label.textContent = `↗ Query from ${fromAgent}`; + msgsEl.appendChild(label); + + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg user"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + msgDiv.textContent = message; + msgsEl.appendChild(msgDiv); + } + + const thinkingEl = document.createElement("div"); + thinkingEl.className = "chat-thinking"; + thinkingEl.innerHTML = ""; + msgsEl.appendChild(thinkingEl); + + /** @type {DelegationState} */ + const state = { + id: nodeId, from: fromAgent, to: toAgent, + nodeEl: node, messagesEl: msgsEl, activityEl, + response: "", thinkingEl, activeTools: new Map(), children: [], + }; + activeDelegations.set(nodeId, state); + + const targetAgent = agents.get(toAgent); + if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.add("delegating"); + + updateTreePanelVisibility(); + setTimeout(() => { node.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, 100); + return state; + } + + function endDelegationNode(/** @type {string} */ nodeId) { + const del = activeDelegations.get(nodeId); + if (!del) return; + + if (del.thinkingEl) { del.thinkingEl.remove(); del.thinkingEl = null; } + + if (del.response && del.messagesEl) { + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg agent"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + msgDiv.innerHTML = marked.parse(del.response); + del.messagesEl.appendChild(msgDiv); + } + + const card = del.nodeEl ? $(".delegation-card", del.nodeEl) : null; + if (card) { card.classList.remove("active"); card.classList.add("completed"); } + + const arrow = del.nodeEl ? $(".delegation-connector-arrow", del.nodeEl) : null; + if (arrow) { + arrow.innerHTML = ` + + `; + } + + const targetAgent = agents.get(del.to); + if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.remove("delegating"); + + activeDelegations.delete(nodeId); + updateTreePanelVisibility(); + } + + function getDelegationChildContainer(/** @type {DelegationState} */ parentDel) { + if (!parentDel.nodeEl) return delegationTree; + let nested = /** @type {HTMLElement|null} */ ($(".delegation-nested", parentDel.nodeEl)); + if (!nested) { + nested = document.createElement("div"); + nested.className = "delegation-nested delegation-tree"; + parentDel.nodeEl.appendChild(nested); + } + return nested; + } + + /* ════════════════════════════════════════════════════════════ + SSE EVENT ROUTER + ════════════════════════════════════════════════════════════ */ + + function findInTree(/** @type {any[]} */ tree, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { + for (const n of tree) { + if (n.id === nodeId) return { node: n, ancestors }; + if (n.children?.length) { + const r = findInTree(n.children, nodeId, [...ancestors, n]); + if (r) return r; + } + } + return null; + } + + function getDelegationForEvent(/** @type {any[]} */ tree, /** @type {string} */ nodeId) { + if (!nodeId || !tree) return null; + const r = findInTree(tree, nodeId); + if (!r) return null; + for (const anc of r.ancestors) { + if (anc.kind === "delegation") { + const del = activeDelegations.get(anc.id); + if (del) return del; + } + } + return null; + } + + function handleSSEEvent(/** @type {any} */ evt) { + const event = evt.event; + switch (event) { + case "turn_complete": + hideThinking(); + if (evt.response) addMessage("agent", evt.response); + for (const [nid] of activeDelegations) endDelegationNode(nid); + break; + + case "turn_error": + hideThinking(); + addMessage("error", evt.error || "Unknown error"); + for (const [nid] of activeDelegations) endDelegationNode(nid); + break; + + case "tool_start": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (del) showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, del.activityEl, del.activeTools); + else showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, null, null); + break; + } + + case "tool_end": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (del) showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, del.activityEl, del.activeTools); + else showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, null, null); + break; + } + + case "delegation_start": { + const fromAgent = evt.from_agent || selectedAgent || ""; + const toAgent = evt.to_agent; + let parentContainer = delegationTree; + for (const del of activeDelegations.values()) { + if (del.to === fromAgent) { parentContainer = getDelegationChildContainer(del); break; } + } + createDelegationNode(evt.node_id, fromAgent, toAgent, evt.message || "", parentContainer); + break; + } + + case "delegation_end": { + const del = activeDelegations.get(evt.node_id); + if (del) del.response = evt.response || ""; + endDelegationNode(evt.node_id); + break; + } + + case "llm_start": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (!del) showThinking(); + break; + } + + case "llm_end": { + const del = getDelegationForEvent(evt.tree, evt.node_id); + if (!del) hideThinking(); + break; + } + + default: break; + } + } + + /* ════════════════════════════════════════════════════════════ + AGENT CRUD MODAL + ════════════════════════════════════════════════════════════ */ + + function openCreateModal() { + editingAgent = null; + agentForm.reset(); + afName.disabled = false; + afSubmit.textContent = "Create"; + afDelete.classList.add("hidden"); + modalTitle.textContent = "New Agent"; + modalOverlay.classList.remove("hidden"); + } + + async function openEditModal(/** @type {string} */ name) { + editingAgent = name; + afName.disabled = true; + afSubmit.textContent = "Save"; + afDelete.classList.remove("hidden"); + modalTitle.textContent = `Edit ${name}`; + modalOverlay.classList.remove("hidden"); + try { + const res = await api(`/api/agents/${name}`); + const data = await res.json(); + afName.value = name; + afDesc.value = data.description || ""; + afPrompt.value = data.system_prompt || ""; + afModel.value = data.model || ""; + afTools.value = (data.tools || []).join(", "); + afSubs.value = (data.sub_agents || []).join(", "); + } catch { agentForm.reset(); afName.value = name; } + } + + function closeModal() { modalOverlay.classList.add("hidden"); } + + async function submitAgent(/** @type {Event} */ e) { + e.preventDefault(); + const payload = { + name: afName.value.trim(), + description: afDesc.value.trim() || "Agent", + system_prompt: afPrompt.value.trim(), + model: afModel.value.trim() || "${default}", + tools: afTools.value ? afTools.value.split(",").map(s => s.trim()).filter(Boolean) : [], + sub_agents: afSubs.value ? afSubs.value.split(",").map(s => s.trim()).filter(Boolean) : [], + }; + try { + if (editingAgent) await api(`/api/agents/${editingAgent}`, { method: "PUT", body: JSON.stringify(payload) }); + else await api("/api/agents", { method: "POST", body: JSON.stringify(payload) }); + closeModal(); + await reloadAgents(); + } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } + } + + async function deleteCurrentAgent() { + if (!editingAgent || !confirm(`Delete agent "${editingAgent}"?`)) return; + try { + await api(`/api/agents/${editingAgent}`, { method: "DELETE" }); + closeModal(); + const agent = agents.get(editingAgent); + if (agent?.sidebarEl) agent.sidebarEl.remove(); + agents.delete(editingAgent); + if (editingAgent === selectedAgent) { + selectedAgent = null; + if (agents.size > 0) selectAgent(/** @type {string} */(agents.keys().next().value)); + else { emptyState.classList.remove("hidden"); primaryChat.classList.add("hidden"); sidebar.classList.remove("collapsed"); } + } + } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } + } + + async function reloadAgents() { + for (const a of agents.values()) { if (a.sidebarEl) a.sidebarEl.remove(); } + agents.clear(); + selectedAgent = null; + sidebar.classList.remove("collapsed"); + emptyState.classList.remove("hidden"); + primaryChat.classList.add("hidden"); + activeDelegations.clear(); + delegationTree.innerHTML = ""; + chatMessagesInner.innerHTML = ""; + chatActivity.innerHTML = ""; + updateTreePanelVisibility(); + await loadAgents(); + } + + /* ════════════════════════════════════════════════════════════ + EVENT BINDINGS (tracked for cleanup) + ════════════════════════════════════════════════════════════ */ + /** @type {Array<[EventTarget, string, EventListener]>} */ + const listeners = []; + + function on(/** @type {EventTarget} */ el, /** @type {string} */ evt, /** @type {EventListener} */ fn) { + el.addEventListener(evt, fn); + listeners.push([el, evt, fn]); + } + + on(chatInput, "keydown", (e) => { + if (/** @type {KeyboardEvent} */ (e).key === "Enter" && !/** @type {KeyboardEvent} */ (e).shiftKey) { + e.preventDefault(); sendMessage(); + } + }); + on(chatSendBtn, "click", () => sendMessage()); + on(ref("btn-edit-agent"), "click", () => { if (selectedAgent) openEditModal(selectedAgent); }); + on(ref("btn-add-agent"), "click", openCreateModal); + on(ref("btn-refresh-agents"), "click", async () => { + const btn = ref("btn-refresh-agents"); + btn.classList.add("spinning"); + try { await reloadAgents(); } finally { setTimeout(() => btn.classList.remove("spinning"), 400); } + }); + on(sidebar, "click", (e) => { + if (!sidebar.classList.contains("collapsed")) return; + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent")) return; + sidebar.classList.toggle("expanded"); + }); + on(document, "click", (e) => { + if (!sidebar.classList.contains("expanded")) return; + if (!sidebar.contains(/** @type {Node} */(e.target))) { + sidebar.classList.remove("expanded"); + } + }); + on(ref("modal-close"), "click", closeModal); + on(modalOverlay, "click", (e) => { if (e.target === modalOverlay) closeModal(); }); + on(afCancel, "click", closeModal); + on(agentForm, "submit", submitAgent); + on(afDelete, "click", deleteCurrentAgent); + + /* ════════════════════════════════════════════════════════════ + INIT + ════════════════════════════════════════════════════════════ */ + loadAgents(); + + /* ════════════════════════════════════════════════════════════ + DESTROY — cleanup for unmount + ════════════════════════════════════════════════════════════ */ + return { + destroy() { + destroyed = true; + for (const [el, evt, fn] of listeners) el.removeEventListener(evt, fn); + listeners.length = 0; + rootEl.classList.remove("root"); + rootEl.innerHTML = ""; + }, + }; +} diff --git a/src/opensensa/web/static/css/chat.css b/src/opensensa/web/static/css/chat.css new file mode 100644 index 0000000..090a11b --- /dev/null +++ b/src/opensensa/web/static/css/chat.css @@ -0,0 +1,425 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Chat Panel ──────────────────────────────────────────── */ +/* Tokens used: --bg, --surface, --surface-alt, --surface-hover, + --border, --border-light, --accent, --accent-glow, --accent-light, + --text, --text-mid, --text-muted, --text-faint, --error, --error-bg, + --font, --mono, --radius, --radius-sm, --radius-xs, --radius-pill, + --shadow-card */ + +/* ── App Layout ──────────────────────────────────────────── */ +.app-layout { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* ── Main Area ───────────────────────────────────────────── */ +.main-area { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); + position: relative; + padding: 20px 24px; + transition: flex .3s cubic-bezier(.4, 0, .2, 1); +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} + +.empty-state svg { opacity: .2; stroke: var(--text-faint); } +.empty-state p { font-size: 15px; font-weight: 600; color: var(--text-mid); } +.empty-state span { font-size: 13px; } + +/* ── Primary Chat Panel ──────────────────────────────────── */ +.chat-panel { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--border-light); + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + animation: panelSlideIn .25s ease; +} + +@keyframes panelSlideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-panel.panel-enter { + animation: panelEnter .3s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes panelEnter { + 0% { opacity: 0; transform: translateX(-16px) scale(.98); } + 100% { opacity: 1; transform: translateX(0) scale(1); } +} + +/* ── Chat Header ─────────────────────────────────────────── */ +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.chat-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.chat-status-dot.thinking { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +@keyframes dotBreathe { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .5; transform: scale(.75); } +} + +.chat-agent-name { + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: -.2px; +} + +.chat-agent-model { + font-size: 10.5px; + font-weight: 600; + font-family: var(--mono); + color: var(--text-muted); + background: var(--surface-alt); + padding: 2px 7px; + border-radius: 4px; + letter-spacing: .3px; +} + +.icon-btn { + width: 30px; + height: 30px; + border: none; + background: none; + color: var(--text-muted); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.icon-btn:hover { + background: var(--surface-alt); + color: var(--text); +} + +/* ── Chat Messages ───────────────────────────────────────── */ +.chat-messages { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.chat-messages::-webkit-scrollbar { width: 4px; } +.chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.chat-messages-inner { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Messages */ +.chat-msg { + font-size: 13.5px; + line-height: 1.6; + max-width: 80%; + padding: 10px 14px; + border-radius: var(--radius-sm); + word-break: break-word; + animation: msgUp .2s ease; +} + +@keyframes msgUp { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-msg.user { + align-self: flex-end; + background: var(--text); + color: var(--surface); + border-bottom-right-radius: 4px; +} + +.chat-msg.agent { + align-self: flex-start; + background: var(--surface-alt); + color: var(--text); + border-bottom-left-radius: 4px; + border: 1px solid var(--border-light); +} + +.chat-msg.agent p { margin: 0 0 .3em; } +.chat-msg.agent p:last-child { margin: 0; } + +.chat-msg.agent pre { + background: #2C2B27; + color: #E8E2D9; + padding: 10px 12px; + border-radius: 8px; + margin: 6px 0; + overflow-x: auto; + font-size: 12px; + font-family: var(--mono); +} + +.chat-msg.agent code { font-family: var(--mono); font-size: .88em; } + +.chat-msg.agent :not(pre)>code { + background: rgba(55, 53, 47, .06); + padding: 1px 5px; + border-radius: 4px; +} + +.chat-msg.user :not(pre)>code { + background: rgba(255, 255, 255, .15); +} + +.chat-msg.error-msg { + align-self: center; + background: var(--error-bg); + color: var(--error); + font-size: 12.5px; + text-align: center; + border-radius: var(--radius-pill); + padding: 5px 16px; +} + +.chat-msg.system-msg { + align-self: center; + background: none; + color: var(--text-muted); + font-size: 11.5px; + text-align: center; + padding: 2px 8px; +} + +/* ── Thinking Dots ───────────────────────────────────────── */ +.chat-thinking { + display: flex; + gap: 4px; + padding: 8px 12px; + align-self: flex-start; +} + +.chat-thinking span { + width: 6px; + height: 6px; + background: var(--accent-light); + border-radius: 50%; + animation: dots .7s ease-in-out infinite; +} + +.chat-thinking span:nth-child(2) { animation-delay: .12s; } +.chat-thinking span:nth-child(3) { animation-delay: .24s; } + +@keyframes dots { + 0%, 80%, 100% { transform: scale(.5); opacity: .3; } + 40% { transform: scale(1); opacity: 1; } +} + +/* ── Chat Activity (Tool Chips) ──────────────────────────── */ +.chat-activity { + min-height: 0; + overflow: hidden; + padding: 0 20px; + transition: padding .2s, min-height .2s; +} + +.chat-activity:not(:empty) { + padding: 6px 20px; + min-height: 32px; +} + +.tool-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + padding: 3px 8px; + border-radius: 6px; + margin: 2px 2px; + animation: chipPop .15s ease; +} + +@keyframes chipPop { + from { opacity: 0; transform: scale(.95); } + to { opacity: 1; transform: scale(1); } +} + +.tool-chip.running { + background: var(--accent-bg); + color: var(--accent); + border: 1px solid var(--accent-muted); +} + +.tool-chip.complete { + background: var(--success-bg); + color: var(--success); + border: 1px solid #C6E7D0; +} + +.tool-chip.failed { + background: var(--error-bg); + color: var(--error); + border: 1px solid #F5D0CD; +} + +.tool-chip-icon { font-size: 11px; } + +.spinner { + width: 9px; + height: 9px; + border: 1.5px solid var(--accent-muted); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .65s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Context Headers Bar ─────────────────────────────────── */ +.context-headers-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--border-light); + background: var(--bg-body); + flex-shrink: 0; +} + +.context-headers-bar.hidden { display: none; } + +.ctx-header-field { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 200px; + min-width: 180px; +} + +.ctx-header-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.ctx-header-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 5px 10px; + font-family: var(--font); + font-size: 12.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s; +} + +.ctx-header-input:focus { border-color: var(--accent); } +.ctx-header-input::placeholder { color: var(--text-faint); font-size: 11px; } + +/* ── Chat Input ──────────────────────────────────────────── */ +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 10px 14px; + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s, box-shadow .15s; +} + +.chat-input::placeholder { color: var(--text-faint); font-size: 13px; } + +.chat-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.chat-send-btn { + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius-xs); + background: var(--text); + color: var(--surface); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .15s; + flex-shrink: 0; +} + +.chat-send-btn svg { width: 15px; height: 15px; } +.chat-send-btn:hover { background: #2C2B27; } +.chat-send-btn:disabled { opacity: .25; cursor: not-allowed; } diff --git a/src/opensensa/web/static/css/delegation-tree.css b/src/opensensa/web/static/css/delegation-tree.css new file mode 100644 index 0000000..ba28575 --- /dev/null +++ b/src/opensensa/web/static/css/delegation-tree.css @@ -0,0 +1,260 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Delegation Tree Panel + Nodes ───────────────────────── */ +/* + * Self-contained styles for the DelegationTree widget. + * + * Required tokens (define on a parent element or use tokens.css): + * --accent, --accent-dark, --accent-light, --accent-muted, --accent-bg, + * --accent-glow, --surface, --surface-alt, --border, --border-light, + * --text, --text-muted, --text-faint, --success, --success-bg, + * --error, --error-bg, --font, --mono, --radius-sm, --radius-xs, + * --shadow-card + */ + +/* ── Link Arrow (between chat and tree) ──────────────────── */ +.tree-link-arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + opacity: 0; + transition: opacity .3s ease; +} + +.tree-link-arrow.visible { opacity: 1; } + +/* ── Tree Panel Container ────────────────────────────────── */ +.tree-panel { + flex: 0 0 35%; + min-width: 0; + background: var(--surface); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tree-panel-header { + padding: 16px 18px 10px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-light); +} + +.tree-panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.tree-panel-hint { + display: block; + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; + line-height: 1.4; +} + +.tree-panel .delegation-tree { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 10px 20px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar { width: 4px; } +.tree-panel .delegation-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +/* ── Delegation Tree Layout ──────────────────────────────── */ +.delegation-tree { + display: flex; + flex-direction: column; + align-items: stretch; + position: relative; +} + +/* ── Delegation Nodes ────────────────────────────────────── */ +.delegation-node { + position: relative; + margin-top: 0; + padding-left: 24px; + animation: delegationSlideIn .35s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes delegationSlideIn { + from { opacity: 0; transform: translateY(-12px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* Vertical connector */ +.delegation-node::before { + content: ''; + position: absolute; + left: 12px; + top: -16px; + width: 2px; + height: 32px; + background: var(--accent-light); +} + +/* Horizontal connector */ +.delegation-node::after { + content: ''; + position: absolute; + left: 12px; + top: 16px; + width: 16px; + height: 2px; + background: var(--accent-light); +} + +.delegation-connector-arrow { + position: absolute; + left: 6px; + top: 10px; + width: 14px; + height: 14px; + color: var(--accent); + z-index: 1; +} + +/* ── Delegation Card ─────────────────────────────────────── */ +.delegation-card { + background: var(--surface); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--accent-muted); + width: 100%; + overflow: hidden; + transition: border-color .3s, box-shadow .3s; +} + +.delegation-card.active { + border-color: var(--accent); + box-shadow: var(--shadow-card), 0 0 0 3px var(--accent-glow); +} + +.delegation-card.completed { + border-color: var(--border-light); + opacity: .85; +} + +.delegation-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-light); + background: var(--accent-bg); +} + +.delegation-card-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.delegation-card.active .delegation-card-dot { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +@keyframes dotBreathe { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: .5; transform: scale(.75); } +} + +.delegation-card.completed .delegation-card-dot { background: var(--success); } + +.delegation-card-name { + font-size: 12.5px; + font-weight: 700; + color: var(--text); + flex: 1; + letter-spacing: -.15px; +} + +.delegation-card-from { + font-size: 10px; + color: var(--accent-dark); + font-weight: 500; +} + +.delegation-card-messages { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.delegation-card-messages::-webkit-scrollbar { width: 3px; } +.delegation-card-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.delegation-card-activity { padding: 0 14px; } +.delegation-card-activity:not(:empty) { padding: 4px 14px 8px; } + +.delegation-node .delegation-node { margin-top: 0; } + +/* ── Tool chips (used inside delegation cards) ───────────── */ +/* Inherits .tool-chip, .spinner from chat.css if present; + duplicated here for standalone use. */ +.delegation-card .tool-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + padding: 3px 8px; + border-radius: 6px; + margin: 2px 2px; +} + +.delegation-card .tool-chip.running { + background: var(--accent-bg); + color: var(--accent); + border: 1px solid var(--accent-muted); +} + +.delegation-card .tool-chip.complete { + background: var(--success-bg); + color: var(--success); + border: 1px solid #C6E7D0; +} + +.delegation-card .tool-chip.failed { + background: var(--error-bg); + color: var(--error); + border: 1px solid #F5D0CD; +} + +.delegation-card .tool-chip-icon { font-size: 11px; } + +.delegation-card .spinner { + width: 9px; + height: 9px; + border: 1.5px solid var(--accent-muted); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .65s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Delegation From Label ───────────────────────────────── */ +.delegation-from-label { + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + padding: 2px 0; + display: flex; + align-items: center; + gap: 4px; +} diff --git a/src/opensensa/web/static/css/modal.css b/src/opensensa/web/static/css/modal.css new file mode 100644 index 0000000..7925062 --- /dev/null +++ b/src/opensensa/web/static/css/modal.css @@ -0,0 +1,163 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Agent CRUD Modal ────────────────────────────────────── */ +/* Tokens used: --surface, --surface-alt, --border, --border-light, + --accent, --accent-glow, --text, --text-muted, --error, --error-bg, + --font, --radius, --radius-xs, --shadow-lg */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(55, 53, 47, .25); + backdrop-filter: blur(3px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn .12s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--surface); + border-radius: var(--radius); + width: 460px; + max-height: 85vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border-light); + animation: modalSlide .2s ease; +} + +@keyframes modalSlide { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--border-light); +} + +.modal-header h2 { + font-size: 15px; + font-weight: 700; + letter-spacing: -.2px; +} + +.modal-close { + width: 30px; + height: 30px; + border: none; + background: var(--surface-alt); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.modal-close:hover { + background: var(--error-bg); + color: var(--error); +} + +.modal-body { padding: 18px 22px; } + +.form-group { margin-bottom: 14px; } + +.form-group label { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--text); + margin-bottom: 5px; + letter-spacing: .2px; +} + +.form-hint { + font-weight: 400; + color: var(--text-muted); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + transition: border-color .15s, box-shadow .15s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.form-group textarea { + min-height: 80px; + resize: vertical; + line-height: 1.5; +} + +.form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 6px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 9px 18px; + border: none; + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all .15s; +} + +.btn-primary { + background: var(--text); + color: var(--surface); +} + +.btn-primary:hover { background: #2C2B27; } + +.btn-ghost { + background: none; + color: var(--text-muted); +} + +.btn-ghost:hover { + background: var(--surface-alt); + color: var(--text); +} + +.btn-danger { + background: var(--error); + color: #fff; +} + +.btn-danger:hover { background: #B04840; } diff --git a/src/opensensa/web/static/css/sidebar.css b/src/opensensa/web/static/css/sidebar.css new file mode 100644 index 0000000..5a12472 --- /dev/null +++ b/src/opensensa/web/static/css/sidebar.css @@ -0,0 +1,231 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Sidebar ─────────────────────────────────────────────── */ +/* Tokens used: --sidebar-w, --surface, --border-light, --text-muted, + --accent, --accent-bg, --success, --success-bg, --text, --text-faint, + --surface-hover, --surface-alt, --text-mid, --radius-xs, --shadow-lg, + --border */ + +.sidebar { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; + transition: flex-basis .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1), box-shadow .3s ease; +} + +.sidebar-header { + padding: 16px 18px 10px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-refresh-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color .2s, background .2s, transform .4s ease; +} + +.sidebar-refresh-btn:hover { + color: var(--accent); + background: var(--bg-hover); +} + +.sidebar-refresh-btn.spinning svg { + animation: spin-once .5s ease-in-out; +} + +@keyframes spin-once { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.sidebar-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.sidebar-list { + flex: 1; + overflow-y: auto; + padding: 0 8px 12px; +} + +.sidebar-list::-webkit-scrollbar { width: 4px; } +.sidebar-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +/* ── Sidebar Collapsed State ─────────────────────────────── */ +.sidebar.collapsed { + flex: 0 0 48px; + width: 48px; + position: relative; + z-index: 50; +} + +.sidebar.collapsed .sidebar-header { + padding: 14px 0 8px; + display: flex; + justify-content: center; +} + +.sidebar.collapsed .sidebar-refresh-btn { display: none; } + +.sidebar.collapsed .sidebar-title { + font-size: 0; + width: 20px; + height: 20px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; +} + +.sidebar.collapsed .sidebar-title::after { + content: ''; + display: block; + width: 16px; + height: 2px; + background: var(--text-muted); + border-radius: 2px; + box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); +} + +.sidebar.collapsed .sidebar-list { display: none; } + +/* Click-expanded overlay */ +.sidebar.collapsed.expanded { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + box-shadow: var(--shadow-lg); + z-index: 100; +} + +.sidebar.collapsed.expanded .sidebar-header { + padding: 16px 18px 10px; + justify-content: space-between; +} + +.sidebar.collapsed.expanded .sidebar-title { + font-size: 11px; + width: auto; + height: auto; +} + +.sidebar.collapsed.expanded .sidebar-title::after { display: none; } +.sidebar.collapsed.expanded .sidebar-refresh-btn { display: flex; } + +.sidebar.collapsed.expanded .sidebar-list { + display: block; + padding: 0 8px 12px; +} + +.sidebar.collapsed .sidebar-header { cursor: pointer; } + +/* ── Sidebar Agent Entry ─────────────────────────────────── */ +.sidebar-agent { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 2px; + border-radius: var(--radius-xs); + cursor: pointer; + transition: background .2s ease, box-shadow .2s ease, transform .15s ease; + position: relative; + user-select: none; +} + +.sidebar-agent:active { transform: scale(.98); } +.sidebar-agent:hover { background: var(--surface-hover); } + +.sidebar-agent.active { + background: var(--accent-bg); + box-shadow: inset 3px 0 0 var(--accent); + animation: sidebarSelect .3s ease; +} + +@keyframes sidebarSelect { + from { background: var(--surface-hover); transform: translateX(0); } + 40% { transform: translateX(4px); } + to { background: var(--accent-bg); transform: translateX(0); } +} + +.sidebar-agent.delegating { background: var(--success-bg); } + +.sidebar-agent-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-faint); + flex-shrink: 0; + margin-top: 5px; + transition: background .3s; +} + +.sidebar-agent.active .sidebar-agent-dot { background: var(--accent); } +.sidebar-agent.delegating .sidebar-agent-dot { background: var(--success); } + +.sidebar-agent-info { + flex: 1; + min-width: 0; +} + +.sidebar-agent-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -.15px; +} + +.sidebar-agent-desc { + font-size: 11px; + color: var(--text-muted); + display: block; + margin-top: 2px; + line-height: 1.4; +} + +.sidebar-agent-edit { + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-faint); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all .12s; + flex-shrink: 0; +} + +.sidebar-agent:hover .sidebar-agent-edit { opacity: 1; } + +.sidebar-agent-edit:hover { + background: var(--surface-alt); + color: var(--text-mid); +} diff --git a/src/opensensa/web/static/css/tokens.css b/src/opensensa/web/static/css/tokens.css new file mode 100644 index 0000000..eeeea15 --- /dev/null +++ b/src/opensensa/web/static/css/tokens.css @@ -0,0 +1,84 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Design Tokens (scoped to .root) ─────────────── */ +/* + * Required tokens for all opensensa widgets. + * External consumers: define these variables on your own container to + * customise look-and-feel without touching component CSS. + */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +.root { + /* Palette */ + --bg: #FAF8F5; + --bg-warm: #F4F0EB; + --surface: #FFFFFF; + --surface-alt: #F7F4F0; + --surface-hover: #F0ECE6; + --border: #E8E2D9; + --border-light: #F0EBE4; + --border-focus: #D4A574; + + --accent: #E8751A; + --accent-dark: #D06515; + --accent-light: #F5C9A0; + --accent-muted: #F9E0C5; + --accent-bg: #FFF7F0; + --accent-glow: rgba(232, 117, 26, .18); + + --text: #37352F; + --text-mid: #6B6760; + --text-muted: #A09B93; + --text-faint: #C4BFB7; + + --success: #3B8C5F; + --success-bg: #EDF7F0; + --warning: #C48B2C; + --error: #C4554D; + --error-bg: #FDF2F2; + --info: #5B8DC9; + + /* Layout */ + --toolbar-h: 50px; + --sidebar-w: 20%; + --radius: 16px; + --radius-sm: 12px; + --radius-xs: 8px; + --radius-pill: 100px; + + /* Type */ + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(55, 53, 47, .04), 0 0 0 1px rgba(55, 53, 47, .04); + --shadow-card: 0 1px 4px rgba(55, 53, 47, .06), 0 0 0 1px rgba(55, 53, 47, .05); + --shadow-card-hover: 0 4px 16px rgba(55, 53, 47, .08), 0 0 0 1px rgba(55, 53, 47, .06); + --shadow-card-active: 0 6px 24px rgba(55, 53, 47, .10), 0 0 0 2px var(--accent); + --shadow-lg: 0 12px 40px rgba(55, 53, 47, .12); + + /* Root container styles */ + font-family: var(--font); + font-size: 14px; + color: var(--text); + background: var(--bg); + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.root *, +.root *::before, +.root *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} diff --git a/src/opensensa/web/static/css/toolbar.css b/src/opensensa/web/static/css/toolbar.css new file mode 100644 index 0000000..0a39f80 --- /dev/null +++ b/src/opensensa/web/static/css/toolbar.css @@ -0,0 +1,83 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Toolbar ─────────────────────────────────────────────── */ +/* Tokens used: --toolbar-h, --surface, --border-light, --font, + --text, --text-mid, --accent, --border, --radius-xs, --surface-hover */ + +.toolbar { + height: var(--toolbar-h); + background: var(--surface); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + gap: 10px; + position: relative; + z-index: 200; + flex-shrink: 0; +} + +.toolbar-section { + display: flex; + align-items: center; + gap: 6px; +} + +.toolbar-right { + margin-left: auto; +} + +.logo { + font-size: 16px; + font-weight: 700; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + letter-spacing: -.3px; + user-select: none; +} + +.logo svg { + color: var(--accent); +} + +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 32px; + min-width: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface); + color: var(--text-mid); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all .15s ease; +} + +.tool-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.tool-btn-accent { + background: var(--text); + border-color: var(--text); + color: var(--surface); + font-weight: 600; +} + +.tool-btn-accent:hover { + background: #2C2B27; + border-color: #2C2B27; + color: var(--surface); +} diff --git a/src/opensensa/web/static/css/utilities.css b/src/opensensa/web/static/css/utilities.css new file mode 100644 index 0000000..03b34f6 --- /dev/null +++ b/src/opensensa/web/static/css/utilities.css @@ -0,0 +1,8 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ + +/* ── Utilities ───────────────────────────────────────────── */ +.hidden { + display: none !important; +} diff --git a/src/opensensa/web/static/modules/agent-modal.js b/src/opensensa/web/static/modules/agent-modal.js new file mode 100644 index 0000000..c3e0fa5 --- /dev/null +++ b/src/opensensa/web/static/modules/agent-modal.js @@ -0,0 +1,225 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ +// @ts-check + +/** + * AgentModal — Self-contained CRUD modal for creating/editing/deleting agents. + * + * The modal does NOT call APIs itself — it emits events ('submitted', 'deleted') + * so the parent orchestrator can handle HTTP calls. This makes it fully reusable + * in any context. + * + * Usage: + * import { AgentModal } from './agent-modal.js'; + * const modal = new AgentModal(document.getElementById('modal-host')); + * modal.on('submitted', payload => fetch('/api/agents', { body: JSON.stringify(payload) })); + * modal.on('deleted', ({ name }) => fetch(`/api/agents/${name}`, { method: 'DELETE' })); + * modal.openCreate(); + * modal.openEdit({ name: 'my-agent', description: 'Helper', ... }); + * + * Required CSS: css/modal.css (+ css/tokens.css or custom token values) + * External JS dependencies: NONE + */ + +/** + * @typedef {Object} AgentFormData + * @property {string} name + * @property {string} description + * @property {string} system_prompt + * @property {string} model + * @property {string[]} tools + * @property {string[]} sub_agents + */ + +const MODAL_TEMPLATE = ` +`; + +export class AgentModal { + /** @type {HTMLElement} */ _overlay; + /** @type {HTMLElement} */ _title; + /** @type {HTMLFormElement} */ _form; + /** @type {HTMLInputElement} */ _afName; + /** @type {HTMLInputElement} */ _afDesc; + /** @type {HTMLTextAreaElement} */ _afPrompt; + /** @type {HTMLInputElement} */ _afModel; + /** @type {HTMLInputElement} */ _afTools; + /** @type {HTMLInputElement} */ _afSubs; + /** @type {HTMLElement} */ _afSubmit; + /** @type {HTMLElement} */ _afDelete; + + /** @type {string|null} */ _editingAgent = null; + /** @type {Record} */ _handlers = {}; + /** @type {Array<[EventTarget, string, EventListener]>} */ _listeners = []; + + /** + * @param {HTMLElement} containerEl — overlay will be appended inside this element + */ + constructor(containerEl) { + this._overlay = document.createElement("div"); + this._overlay.className = "modal-overlay hidden"; + this._overlay.innerHTML = MODAL_TEMPLATE; + containerEl.appendChild(this._overlay); + + const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */ (this._overlay.querySelector(`[data-ref="${n}"]`)); + this._title = ref("modal-title"); + this._form = /** @type {HTMLFormElement} */ (ref("agent-form")); + this._afName = /** @type {HTMLInputElement} */ (ref("af-name")); + this._afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); + this._afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); + this._afModel = /** @type {HTMLInputElement} */ (ref("af-model")); + this._afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); + this._afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); + this._afSubmit = ref("af-submit"); + this._afDelete = ref("af-delete"); + + // Event bindings + this._on(ref("modal-close"), "click", () => this.close()); + this._on(this._overlay, "click", (/** @type {MouseEvent} */ e) => { + if (e.target === this._overlay) this.close(); + }); + this._on(ref("af-cancel"), "click", () => this.close()); + this._on(this._form, "submit", (/** @type {Event} */ e) => this._onSubmit(e)); + this._on(this._afDelete, "click", () => this._onDelete()); + } + + /* ─── Public API ─────────────────────────────────────── */ + + /** Open the modal in "Create" mode with a blank form. */ + openCreate() { + this._editingAgent = null; + this._form.reset(); + this._afName.disabled = false; + this._afSubmit.textContent = "Create"; + this._afDelete.classList.add("hidden"); + this._title.textContent = "New Agent"; + this._overlay.classList.remove("hidden"); + } + + /** + * Open the modal in "Edit" mode with pre-filled data. + * @param {{ name: string, description?: string, system_prompt?: string, model?: string, tools?: string[], sub_agents?: string[] }} agentData + */ + openEdit(agentData) { + this._editingAgent = agentData.name; + this._afName.disabled = true; + this._afSubmit.textContent = "Save"; + this._afDelete.classList.remove("hidden"); + this._title.textContent = `Edit ${agentData.name}`; + this._overlay.classList.remove("hidden"); + + this._afName.value = agentData.name; + this._afDesc.value = agentData.description || ""; + this._afPrompt.value = agentData.system_prompt || ""; + this._afModel.value = agentData.model || ""; + this._afTools.value = (agentData.tools || []).join(", "); + this._afSubs.value = (agentData.sub_agents || []).join(", "); + } + + /** Close the modal. */ + close() { + this._overlay.classList.add("hidden"); + this._emit("closed", {}); + } + + /** @returns {boolean} */ + get isOpen() { return !this._overlay.classList.contains("hidden"); } + + /** @returns {string|null} — the name of the agent being edited, or null if creating */ + get editingAgent() { return this._editingAgent; } + + /** Destroy the widget. */ + destroy() { + for (const [el, evt, fn] of this._listeners) el.removeEventListener(evt, fn); + this._listeners.length = 0; + this._overlay.remove(); + this._handlers = {}; + } + + /** + * Register an event handler. + * Events: 'submitted', 'deleted', 'closed' + * @param {string} event + * @param {Function} handler + */ + on(event, handler) { + (this._handlers[event] ??= []).push(handler); + } + + /* ─── Private ────────────────────────────────────────── */ + + /** @param {string} event @param {any} data */ + _emit(event, data) { + for (const fn of this._handlers[event] || []) fn(data); + } + + /** @param {EventTarget} el @param {string} evt @param {EventListener} fn */ + _on(el, evt, fn) { + el.addEventListener(evt, fn); + this._listeners.push([el, evt, fn]); + } + + /** @param {Event} e */ + _onSubmit(e) { + e.preventDefault(); + /** @type {AgentFormData} */ + const payload = { + name: this._afName.value.trim(), + description: this._afDesc.value.trim() || "Agent", + system_prompt: this._afPrompt.value.trim(), + model: this._afModel.value.trim() || "${default}", + tools: this._afTools.value ? this._afTools.value.split(",").map(s => s.trim()).filter(Boolean) : [], + sub_agents: this._afSubs.value ? this._afSubs.value.split(",").map(s => s.trim()).filter(Boolean) : [], + }; + this._emit("submitted", { payload, isEdit: !!this._editingAgent, name: this._editingAgent }); + } + + _onDelete() { + if (!this._editingAgent) return; + if (!confirm(`Delete agent "${this._editingAgent}"?`)) return; + this._emit("deleted", { name: this._editingAgent }); + } +} diff --git a/src/opensensa/web/static/modules/api.js b/src/opensensa/web/static/modules/api.js new file mode 100644 index 0000000..21737f5 --- /dev/null +++ b/src/opensensa/web/static/modules/api.js @@ -0,0 +1,81 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ +// @ts-check + +/** + * API client — thin convenience wrapper around fetch for OpenSensa REST endpoints. + * + * Not a dependency for any widget. Only the orchestrator (app.js) uses this to + * wire widget events to server calls. + * + * Usage: + * import { createApi } from './api.js'; + * const api = createApi('https://my-server.com'); + * const agents = await api.fetchAgents(); + */ + +/** + * @typedef {Object} OpenSensaApi + * @property {() => Promise} fetchAgents + * @property {(name: string) => Promise} fetchAgent + * @property {(body: any) => Promise} createAgent + * @property {(name: string, body: any) => Promise} updateAgent + * @property {(name: string) => Promise} deleteAgent + * @property {(agentName: string) => Promise} createSession + */ + +/** + * Create an API client bound to a base URL. + * @param {string} [baseUrl=""] + * @returns {OpenSensaApi} + */ +export function createApi(baseUrl = "") { + const base = baseUrl.replace(/\/+$/, ""); + + /** @param {string} path @param {RequestInit} [opts] */ + const request = (path, opts = {}) => + fetch(base + path, { headers: { "Content-Type": "application/json" }, ...opts }); + + return { + async fetchAgents() { + const res = await request("/api/agents"); + return res.json(); + }, + + async fetchAgent(name) { + const res = await request(`/api/agents/${name}`); + return res.json(); + }, + + async createAgent(body) { + const res = await request("/api/agents", { + method: "POST", + body: JSON.stringify(body), + }); + return res.json(); + }, + + async updateAgent(name, body) { + const res = await request(`/api/agents/${name}`, { + method: "PUT", + body: JSON.stringify(body), + }); + return res.json(); + }, + + async deleteAgent(name) { + const res = await request(`/api/agents/${name}`, { method: "DELETE" }); + return res.json(); + }, + + async createSession(agentName) { + const res = await request("/api/chat/sessions", { + method: "POST", + body: JSON.stringify({ agent_name: agentName }), + }); + const data = await res.json(); + return data.session_id; + }, + }; +} diff --git a/src/opensensa/web/static/modules/chat-panel.js b/src/opensensa/web/static/modules/chat-panel.js new file mode 100644 index 0000000..e49b8f1 --- /dev/null +++ b/src/opensensa/web/static/modules/chat-panel.js @@ -0,0 +1,481 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ +// @ts-check + +/** + * ChatPanel — Self-contained chat widget with SSE streaming, tool chips, + * thinking dots, context-header inputs, and message history. + * + * Usage: + * import { ChatPanel } from './chat-panel.js'; + * const chat = new ChatPanel(document.getElementById('chat-container'), { + * baseUrl: '', + * marked: window.marked, + * hljs: window.hljs, + * }); + * chat.setAgent({ name: 'my-agent', model: 'gpt-4o', contextHeaders: ['X-Doc-URL'] }); + * chat.on('sseEvent', evt => console.log(evt)); + * chat.on('editRequested', name => console.log('edit', name)); + * + * Required CSS: css/chat.css (+ css/tokens.css or custom token values) + * External JS dependencies: marked.js (optional), highlight.js (optional) + */ + +/** + * @typedef {Object} ChatPanelOptions + * @property {string} [baseUrl=""] + * @property {string} [storagePrefix="opensensa"] + * @property {any} [marked] — marked.js instance + * @property {any} [hljs] — highlight.js instance + */ + +/** + * @typedef {Object} ChatAgentConfig + * @property {string} name + * @property {string} model + * @property {string[]} [contextHeaders] + */ + +/** @typedef {{ role: string, content: string }} ChatMessage */ + +const CHAT_TEMPLATE = ` +
+ + + +

Select an agent to start chatting

+ Or create a new agent with the button above +
+ +`; + +export class ChatPanel { + /** @type {HTMLElement} */ _container; + /** @type {string} */ _baseUrl; + /** @type {string} */ _storagePrefix; + /** @type {any} */ _marked; + /** @type {any} */ _hljs; + + // DOM refs + /** @type {HTMLElement} */ _emptyState; + /** @type {HTMLElement} */ _chatPanel; + /** @type {HTMLElement} */ _chatHeader; + /** @type {HTMLElement} */ _chatAgentName; + /** @type {HTMLElement} */ _chatAgentModel; + /** @type {HTMLElement} */ _chatMessages; + /** @type {HTMLElement} */ _chatMessagesInner; + /** @type {HTMLElement} */ _chatActivity; + /** @type {HTMLElement} */ _contextHeadersBar; + /** @type {HTMLInputElement} */ _chatInput; + /** @type {HTMLElement} */ _chatSendBtn; + + // State + /** @type {string|null} */ _agentName = null; + /** @type {string|null} */ _sessionId = null; + /** @type {ChatMessage[]} */ _messages = []; + /** @type {boolean} */ _isSending = false; + /** @type {HTMLElement|null} */ _thinkingEl = null; + /** @type {Map} */ _activeTools = new Map(); + /** @type {string[]} */ _contextHeaders = []; + /** @type {Record} */ _handlers = {}; + /** @type {Array<[EventTarget, string, EventListener]>} */ _listeners = []; + + /** + * @param {HTMLElement} containerEl + * @param {ChatPanelOptions} [options] + */ + constructor(containerEl, options = {}) { + this._container = containerEl; + this._baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); + this._storagePrefix = options.storagePrefix || "opensensa"; + this._marked = options.marked || /** @type {any} */ (globalThis)["marked"] || null; + this._hljs = options.hljs || /** @type {any} */ (globalThis)["hljs"] || null; + + // Configure marked + if (this._marked && this._hljs) { + this._marked.setOptions({ + highlight: (/** @type {string} */ code, /** @type {string} */ lang) => { + if (lang && this._hljs.getLanguage(lang)) return this._hljs.highlight(code, { language: lang }).value; + return this._hljs.highlightAuto(code).value; + }, + breaks: true, + }); + } + + this._container.innerHTML = CHAT_TEMPLATE; + + const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */ (this._container.querySelector(`[data-ref="${n}"]`)); + this._emptyState = ref("empty-state"); + this._chatPanel = ref("chat-panel"); + this._chatHeader = ref("chat-header"); + this._chatAgentName = ref("chat-agent-name"); + this._chatAgentModel = ref("chat-agent-model"); + this._chatMessages = ref("chat-messages"); + this._chatMessagesInner = ref("chat-messages-inner"); + this._chatActivity = ref("chat-activity"); + this._contextHeadersBar = ref("context-headers-bar"); + this._chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); + this._chatSendBtn = ref("chat-send-btn"); + + // Bind events + this._on(this._chatInput, "keydown", (/** @type {KeyboardEvent} */ e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } + }); + this._on(this._chatSendBtn, "click", () => this.sendMessage()); + this._on(ref("btn-edit-agent"), "click", () => { + if (this._agentName) this._emit("editRequested", this._agentName); + }); + } + + /* ─── Public API ─────────────────────────────────────── */ + + /** + * Configure the chat for a specific agent. Clears previous state. + * @param {ChatAgentConfig} agent + * @param {{ messages?: ChatMessage[] }} [restore] — optional restored state + */ + setAgent(agent, restore) { + this._agentName = agent.name; + this._sessionId = null; + this._messages = restore?.messages ? [...restore.messages] : []; + this._isSending = false; + this._thinkingEl = null; + this._activeTools.clear(); + this._contextHeaders = agent.contextHeaders || []; + + this._emptyState.classList.add("hidden"); + this._chatPanel.classList.remove("hidden"); + + // Trigger enter animation + this._chatPanel.classList.remove("panel-enter"); + void this._chatPanel.offsetWidth; + this._chatPanel.classList.add("panel-enter"); + + this._chatAgentName.textContent = agent.name; + this._chatAgentModel.textContent = agent.model || "default"; + + // Render existing messages + this._chatMessagesInner.innerHTML = ""; + for (const msg of this._messages) this._appendMessage(msg.role, msg.content); + this._chatMessages.scrollTop = this._chatMessages.scrollHeight; + + this._chatActivity.innerHTML = ""; + this._chatInput.placeholder = `Ask ${agent.name}…`; + this._renderContextHeaders(); + this._chatInput.focus(); + } + + /** Show the empty state (no agent selected). */ + showEmpty() { + this._agentName = null; + this._emptyState.classList.remove("hidden"); + this._chatPanel.classList.add("hidden"); + } + + /** + * Programmatically add a message. + * @param {string} role + * @param {string} content + */ + addMessage(role, content) { + this._messages.push({ role, content }); + this._appendMessage(role, content); + this._chatMessages.scrollTop = this._chatMessages.scrollHeight; + } + + /** Send the current input value (or programmatically provided text). */ + async sendMessage(/** @type {string} */ text) { + if (!this._agentName || this._isSending) return; + const msg = text || this._chatInput.value.trim(); + if (!msg) return; + + this._isSending = true; + this._chatInput.value = ""; + this.addMessage("user", msg); + + try { + const sid = await this._ensureSession(); + this.showThinking(); + + const extraHeaders = this._getContextHeaderValues(); + const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; + + const res = await fetch(this._baseUrl + `/api/chat/sessions/${sid}/messages`, { + method: "POST", + headers: allHeaders, + body: JSON.stringify({ message: msg }), + }); + + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const raw = line.slice(6); + if (raw === "[DONE]") continue; + try { + const evt = JSON.parse(raw); + this._handleInternalSSE(evt); + this._emit("sseEvent", evt); + } catch { /* skip malformed */ } + } + } + } + } catch (err) { + this.hideThinking(); + this.addMessage("error", "Network error: " + /** @type {Error} */ (err).message); + } + + this._isSending = false; + this.hideThinking(); + this._clearToolChips(); + } + + /** Show the thinking indicator. */ + showThinking() { + if (this._thinkingEl) return; + const el = document.createElement("div"); + el.className = "chat-thinking"; + el.innerHTML = ""; + this._chatMessagesInner.appendChild(el); + this._thinkingEl = el; + const dot = this._chatHeader.querySelector(".chat-status-dot"); + if (dot) dot.classList.add("thinking"); + this._chatMessages.scrollTop = this._chatMessages.scrollHeight; + } + + /** Hide the thinking indicator. */ + hideThinking() { + if (this._thinkingEl) { this._thinkingEl.remove(); this._thinkingEl = null; } + const dot = this._chatHeader.querySelector(".chat-status-dot"); + if (dot) dot.classList.remove("thinking"); + } + + /** + * Show a tool chip in the primary chat activity area. + * @param {string} nodeId + * @param {string} toolName + * @param {string} status — 'running' | 'complete' | 'failed' + * @param {number} [durationMs] + */ + showToolChip(nodeId, toolName, status, durationMs) { + let chip = this._activeTools.get(nodeId); + if (!chip) { + chip = document.createElement("div"); + chip.className = "tool-chip running"; + chip.innerHTML = `🔧${toolName}`; + this._chatActivity.appendChild(chip); + this._activeTools.set(nodeId, chip); + } + + if (status === "complete" || status === "completed") { + chip.className = "tool-chip complete"; + const ms = durationMs != null ? ` · ${durationMs}ms` : ""; + chip.innerHTML = `${toolName}${ms}`; + setTimeout(() => this._activeTools.delete(nodeId), 2000); + } else if (status === "failed") { + chip.className = "tool-chip failed"; + chip.innerHTML = `${toolName}`; + this._activeTools.delete(nodeId); + } + } + + /** Reset the session (clears history, gets new session on next send). */ + reset() { + this._sessionId = null; + this._messages = []; + this._chatMessagesInner.innerHTML = ""; + this._chatActivity.innerHTML = ""; + this._activeTools.clear(); + this.hideThinking(); + } + + /** @returns {{ messages: ChatMessage[] }} — serialisable state for persistence */ + getState() { return { messages: [...this._messages] }; } + + /** @returns {string|null} */ + get agentName() { return this._agentName; } + + /** Clean up DOM and event listeners. */ + destroy() { + for (const [el, evt, fn] of this._listeners) el.removeEventListener(evt, fn); + this._listeners.length = 0; + this._container.innerHTML = ""; + this._handlers = {}; + } + + /** + * Register an event handler. + * Events: 'sseEvent', 'editRequested', 'messageSent' + * @param {string} event + * @param {Function} handler + */ + on(event, handler) { + (this._handlers[event] ??= []).push(handler); + } + + /* ─── Private ────────────────────────────────────────── */ + + /** @param {string} event @param {any} data */ + _emit(event, data) { + for (const fn of this._handlers[event] || []) fn(data); + } + + /** @param {EventTarget} el @param {string} evt @param {EventListener} fn */ + _on(el, evt, fn) { + el.addEventListener(evt, fn); + this._listeners.push([el, evt, fn]); + } + + /** + * @param {string} role + * @param {string} content + */ + _appendMessage(role, content) { + const div = document.createElement("div"); + if (role === "user") { + div.className = "chat-msg user"; + div.textContent = content; + } else if (role === "agent") { + div.className = "chat-msg agent"; + div.innerHTML = this._marked ? this._marked.parse(content) : content; + } else if (role === "error") { + div.className = "chat-msg error-msg"; + div.textContent = content; + } else { + div.className = "chat-msg system-msg"; + div.textContent = content; + } + this._chatMessagesInner.appendChild(div); + } + + /** @returns {Promise} */ + async _ensureSession() { + if (this._sessionId) return this._sessionId; + const res = await fetch(this._baseUrl + "/api/chat/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_name: this._agentName }), + }); + const data = await res.json(); + this._sessionId = data.session_id; + return /** @type {string} */ (this._sessionId); + } + + _renderContextHeaders() { + this._contextHeadersBar.innerHTML = ""; + const keys = this._contextHeaders; + if (!keys.length) { + this._contextHeadersBar.classList.add("hidden"); + return; + } + this._contextHeadersBar.classList.remove("hidden"); + for (const headerName of keys) { + const label = headerName.replace(/^X-/i, "").replace(/-/g, " "); + const wrapper = document.createElement("div"); + wrapper.className = "ctx-header-field"; + wrapper.innerHTML = ``; + const input = document.createElement("input"); + input.type = "text"; + input.className = "ctx-header-input"; + input.placeholder = headerName; + input.dataset.headerName = headerName; + const stored = this._storageGet(`ctx:${this._agentName}:${headerName}`); + if (stored) input.value = stored; + input.addEventListener("change", () => { + this._storageSet(`ctx:${this._agentName}:${headerName}`, input.value); + }); + wrapper.appendChild(input); + this._contextHeadersBar.appendChild(wrapper); + } + } + + /** @returns {Record} */ + _getContextHeaderValues() { + /** @type {Record} */ + const headers = {}; + this._contextHeadersBar.querySelectorAll("input.ctx-header-input").forEach((/** @type {HTMLInputElement} */ el) => { + const name = el.dataset.headerName; + if (name && el.value.trim()) headers[name] = el.value.trim(); + }); + return headers; + } + + /** Handle SSE events that affect chat directly (turn_complete, turn_error, llm_start/end, tool chips in primary). */ + /** @param {any} evt */ + _handleInternalSSE(evt) { + switch (evt.event) { + case "turn_complete": + this.hideThinking(); + if (evt.response) this.addMessage("agent", evt.response); + break; + case "turn_error": + this.hideThinking(); + this.addMessage("error", evt.error || "Unknown error"); + break; + case "llm_start": + // Only show thinking for primary agent (no delegation parent) + // The orchestrator handles delegation-scoped events + break; + case "llm_end": + break; + default: + break; + } + } + + _clearToolChips() { + this._activeTools.clear(); + setTimeout(() => { this._chatActivity.innerHTML = ""; }, 2000); + } + + /** @param {string} key @returns {string|null} */ + _storageGet(key) { + try { return localStorage.getItem(`${this._storagePrefix}-${key}`); } catch { return null; } + } + + /** @param {string} key @param {string} val */ + _storageSet(key, val) { + try { localStorage.setItem(`${this._storagePrefix}-${key}`, val); } catch { } + } +} diff --git a/src/opensensa/web/static/modules/delegation-tree.js b/src/opensensa/web/static/modules/delegation-tree.js new file mode 100644 index 0000000..e4ed298 --- /dev/null +++ b/src/opensensa/web/static/modules/delegation-tree.js @@ -0,0 +1,307 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ +// @ts-check + +/** + * DelegationTree — Self-contained widget for rendering an agent-delegation + * execution tree with tool-chip activity indicators. + * + * Usage: + * import { DelegationTree } from './delegation-tree.js'; + * const tree = new DelegationTree(document.getElementById('my-tree')); + * tree.startDelegation({ id: '1', fromAgent: 'Router', toAgent: 'Search', message: 'find docs' }); + * tree.startTool({ delegationId: '1', nodeId: 't1', toolName: 'web_search' }); + * tree.endTool({ delegationId: '1', nodeId: 't1', toolName: 'web_search', durationMs: 340 }); + * tree.endDelegation({ id: '1', response: 'Found 3 results.' }); + * tree.destroy(); + * + * Required CSS: css/delegation-tree.css (+ css/tokens.css or custom token values) + * External JS dependencies: NONE (optionally pass `marked` in options for + * markdown rendering of delegation responses; falls back to textContent). + */ + +/** + * @typedef {Object} DelegationTreeOptions + * @property {any} [marked] — marked.js instance for rendering markdown responses + * @property {string} [hintText] — text shown when tree is empty + */ + +/** + * @typedef {Object} DelegationNodeState + * @property {string} id + * @property {string} from + * @property {string} to + * @property {HTMLElement} nodeEl + * @property {HTMLElement} messagesEl + * @property {HTMLElement} activityEl + * @property {string} response + * @property {HTMLElement|null} thinkingEl + * @property {Map} activeTools + * @property {DelegationNodeState[]} children + */ + +export class DelegationTree { + /** @type {HTMLElement} */ _container; + /** @type {HTMLElement} */ _treeEl; + /** @type {HTMLElement} */ _hintEl; + /** @type {Map} */ _active = new Map(); + /** @type {Record} */ _handlers = {}; + /** @type {any} */ _marked; + + /** + * @param {HTMLElement} containerEl — the element to render inside + * @param {DelegationTreeOptions} [options] + */ + constructor(containerEl, options = {}) { + this._container = containerEl; + this._marked = options.marked || null; + + // Build own DOM + const hintText = options.hintText || "Delegations appear here when agents hand off tasks"; + this._container.innerHTML = ` +
+ Delegation Tree + ${hintText} +
+
`; + + this._hintEl = /** @type {HTMLElement} */ (this._container.querySelector(".tree-panel-hint")); + this._treeEl = /** @type {HTMLElement} */ (this._container.querySelector(".delegation-tree")); + } + + /* ─── Public API ─────────────────────────────────────── */ + + /** + * Create a new delegation node. + * @param {{ id: string, fromAgent: string, toAgent: string, message?: string, parentId?: string }} opts + * @returns {DelegationNodeState} + */ + startDelegation({ id, fromAgent, toAgent, message = "", parentId }) { + let parentContainer = this._treeEl; + if (parentId) { + const parent = this._active.get(parentId); + if (parent) parentContainer = this._getChildContainer(parent); + } else { + // find parent by matching `toAgent === del.to` pattern (same as legacy) + for (const del of this._active.values()) { + if (del.to === fromAgent) { + parentContainer = this._getChildContainer(del); + break; + } + } + } + + const node = document.createElement("div"); + node.className = "delegation-node"; + node.dataset.nodeId = id; + + node.innerHTML = ` + + + + +
+
+
+ ${toAgent} + from ${fromAgent} +
+
+
+
`; + + parentContainer.appendChild(node); + + const msgsEl = /** @type {HTMLElement} */ (node.querySelector(".delegation-card-messages")); + const activityEl = /** @type {HTMLElement} */ (node.querySelector(".delegation-card-activity")); + + if (message) { + const label = document.createElement("div"); + label.className = "delegation-from-label"; + label.textContent = `↗ Query from ${fromAgent}`; + msgsEl.appendChild(label); + + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg user"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + msgDiv.textContent = message; + msgsEl.appendChild(msgDiv); + } + + const thinkingEl = document.createElement("div"); + thinkingEl.className = "chat-thinking"; + thinkingEl.innerHTML = ""; + msgsEl.appendChild(thinkingEl); + + /** @type {DelegationNodeState} */ + const state = { + id, from: fromAgent, to: toAgent, + nodeEl: node, messagesEl: msgsEl, activityEl, + response: "", thinkingEl, activeTools: new Map(), children: [], + }; + this._active.set(id, state); + this._updateHint(); + + this._emit("delegationStart", { id, fromAgent, toAgent }); + + setTimeout(() => node.scrollIntoView({ behavior: "smooth", block: "nearest" }), 100); + return state; + } + + /** + * Complete a delegation node. + * @param {{ id: string, response?: string }} opts + */ + endDelegation({ id, response }) { + const del = this._active.get(id); + if (!del) return; + + if (response != null) del.response = response; + + if (del.thinkingEl) { del.thinkingEl.remove(); del.thinkingEl = null; } + + if (del.response && del.messagesEl) { + const msgDiv = document.createElement("div"); + msgDiv.className = "chat-msg agent"; + msgDiv.style.fontSize = "12px"; + msgDiv.style.maxWidth = "95%"; + if (this._marked) { + msgDiv.innerHTML = this._marked.parse(del.response); + } else { + msgDiv.textContent = del.response; + } + del.messagesEl.appendChild(msgDiv); + } + + const card = del.nodeEl.querySelector(".delegation-card"); + if (card) { card.classList.remove("active"); card.classList.add("completed"); } + + const arrow = del.nodeEl.querySelector(".delegation-connector-arrow"); + if (arrow) { + arrow.innerHTML = ` + + `; + } + + this._emit("delegationEnd", { id, toAgent: del.to }); + this._active.delete(id); + this._updateHint(); + } + + /** + * Show a running tool chip inside a delegation card. + * @param {{ delegationId: string, nodeId: string, toolName: string }} opts + */ + startTool({ delegationId, nodeId, toolName }) { + const del = this._active.get(delegationId); + if (!del) return; + this._showToolChip(nodeId, toolName, "running", undefined, del.activityEl, del.activeTools); + } + + /** + * Complete a tool chip inside a delegation card. + * @param {{ delegationId: string, nodeId: string, toolName: string, durationMs?: number }} opts + */ + endTool({ delegationId, nodeId, toolName, durationMs }) { + const del = this._active.get(delegationId); + if (!del) return; + this._showToolChip(nodeId, toolName, "complete", durationMs, del.activityEl, del.activeTools); + } + + /** End all active delegations (e.g. on turn_complete or turn_error). */ + endAll() { + for (const [id] of this._active) this.endDelegation({ id }); + } + + /** Clear the tree completely. */ + clear() { + this._treeEl.innerHTML = ""; + this._active.clear(); + this._updateHint(); + } + + /** + * Get the DelegationNodeState for a delegation by ID. + * @param {string} id + * @returns {DelegationNodeState|undefined} + */ + get(id) { return this._active.get(id); } + + /** @returns {boolean} */ + get hasActive() { return this._active.size > 0; } + + /** Clean up DOM and internal state. */ + destroy() { + this._container.innerHTML = ""; + this._active.clear(); + this._handlers = {}; + } + + /** + * Register an event handler. + * Events: 'delegationStart', 'delegationEnd' + * @param {string} event + * @param {Function} handler + */ + on(event, handler) { + (this._handlers[event] ??= []).push(handler); + } + + /* ─── Private ────────────────────────────────────────── */ + + /** @param {string} event @param {any} data */ + _emit(event, data) { + for (const fn of this._handlers[event] || []) fn(data); + } + + _updateHint() { + const hasDelegations = this._treeEl.children.length > 0; + this._hintEl.style.display = hasDelegations ? "none" : "block"; + } + + /** + * @param {DelegationNodeState} parentDel + * @returns {HTMLElement} + */ + _getChildContainer(parentDel) { + let nested = /** @type {HTMLElement|null} */ (parentDel.nodeEl.querySelector(".delegation-nested")); + if (!nested) { + nested = document.createElement("div"); + nested.className = "delegation-nested delegation-tree"; + parentDel.nodeEl.appendChild(nested); + } + return nested; + } + + /** + * @param {string} nodeId + * @param {string} toolName + * @param {string} status + * @param {number|undefined} durationMs + * @param {HTMLElement} area + * @param {Map} toolMap + */ + _showToolChip(nodeId, toolName, status, durationMs, area, toolMap) { + let chip = toolMap.get(nodeId); + if (!chip) { + chip = document.createElement("div"); + chip.className = "tool-chip running"; + chip.innerHTML = `🔧${toolName}`; + area.appendChild(chip); + toolMap.set(nodeId, chip); + } + + if (status === "complete" || status === "completed") { + chip.className = "tool-chip complete"; + const ms = durationMs != null ? ` · ${durationMs}ms` : ""; + chip.innerHTML = `${toolName}${ms}`; + setTimeout(() => toolMap.delete(nodeId), 2000); + } else if (status === "failed") { + chip.className = "tool-chip failed"; + chip.innerHTML = `${toolName}`; + toolMap.delete(nodeId); + } + } +} diff --git a/src/opensensa/web/static/modules/sidebar.js b/src/opensensa/web/static/modules/sidebar.js new file mode 100644 index 0000000..6c36e0f --- /dev/null +++ b/src/opensensa/web/static/modules/sidebar.js @@ -0,0 +1,251 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. — Apache-2.0 + * =========================================================================== */ +// @ts-check + +/** + * Sidebar — Self-contained widget for listing and selecting agents, + * with collapse/expand behaviour. + * + * Usage: + * import { Sidebar } from './sidebar.js'; + * const sidebar = new Sidebar(document.getElementById('sidebar-container')); + * sidebar.loadAgents([{ name: 'router', description: 'Routes tasks', model: 'gpt-4o' }]); + * sidebar.on('agentSelected', ({ name }) => console.log('selected', name)); + * sidebar.on('editRequested', ({ name }) => console.log('edit', name)); + * + * Required CSS: css/sidebar.css (+ css/tokens.css or custom token values) + * External JS dependencies: NONE + */ + +/** + * @typedef {Object} SidebarAgent + * @property {string} name + * @property {string} description + * @property {string} model + * @property {string[]} [tools] + * @property {string[]} [sub_agents] + * @property {string[]} [context_headers] + */ + +/** + * @typedef {Object} SidebarAgentInternal + * @property {SidebarAgent} data + * @property {HTMLElement} el + */ + +export class Sidebar { + /** @type {HTMLElement} */ _container; + /** @type {HTMLElement} */ _sidebarEl; + /** @type {HTMLElement} */ _listEl; + /** @type {HTMLElement} */ _refreshBtn; + /** @type {Map} */ _agents = new Map(); + /** @type {string|null} */ _selected = null; + /** @type {Record} */ _handlers = {}; + /** @type {Array<[EventTarget, string, EventListener]>} */ _listeners = []; + + /** + * @param {HTMLElement} containerEl + */ + constructor(containerEl) { + this._container = containerEl; + + this._container.innerHTML = ` + + `; + + this._sidebarEl = this._container; + this._listEl = /** @type {HTMLElement} */ (this._container.querySelector('[data-ref="sidebar-list"]')); + this._refreshBtn = /** @type {HTMLElement} */ (this._container.querySelector('[data-ref="btn-refresh-agents"]')); + + // Refresh button + this._on(this._refreshBtn, "click", async () => { + this._refreshBtn.classList.add("spinning"); + this._emit("refreshRequested", {}); + setTimeout(() => this._refreshBtn.classList.remove("spinning"), 400); + }); + + // Collapse/expand click on header area + this._on(this._sidebarEl, "click", (/** @type {MouseEvent} */ e) => { + if (!this._sidebarEl.classList.contains("collapsed")) return; + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent")) return; + this._sidebarEl.classList.toggle("expanded"); + }); + + // Close expanded sidebar when clicking outside + this._on(document, "click", (/** @type {MouseEvent} */ e) => { + if (!this._sidebarEl.classList.contains("expanded")) return; + if (!this._sidebarEl.contains(/** @type {Node} */ (e.target))) { + this._sidebarEl.classList.remove("expanded"); + } + }); + } + + /* ─── Public API ─────────────────────────────────────── */ + + /** + * Populate the sidebar from a list of agents. + * Preserves existing entries that are still in the new list. + * @param {SidebarAgent[]} agentList + */ + loadAgents(agentList) { + const incoming = new Set(agentList.map(a => a.name)); + const existing = new Set(this._agents.keys()); + + // Remove agents that are no longer present + for (const name of existing) { + if (!incoming.has(name)) { + const entry = this._agents.get(name); + if (entry?.el) entry.el.remove(); + this._agents.delete(name); + } + } + + // Add new agents + for (const a of agentList) { + if (this._agents.has(a.name)) continue; + + const el = this._createEntry(a); + this._listEl.appendChild(el); + this._agents.set(a.name, { data: a, el }); + } + } + + /** + * Programmatically select an agent by name. + * @param {string} name + */ + selectAgent(name) { + if (!this._agents.has(name)) return; + this._selected = name; + this._sidebarEl.classList.add("collapsed"); + this._sidebarEl.classList.remove("expanded"); + + for (const [n, entry] of this._agents) { + entry.el.classList.toggle("active", n === name); + } + + this._emit("agentSelected", { name, agent: this._agents.get(name)?.data }); + } + + /** + * Highlight an agent as currently being delegated to. + * @param {string} name + * @param {boolean} active + */ + highlightDelegating(name, active) { + const entry = this._agents.get(name); + if (entry?.el) entry.el.classList.toggle("delegating", active); + } + + /** Collapse the sidebar to icon-only mode. */ + collapse() { this._sidebarEl.classList.add("collapsed"); this._sidebarEl.classList.remove("expanded"); } + + /** Expand the sidebar. */ + expand() { this._sidebarEl.classList.remove("collapsed"); } + + /** @returns {string|null} */ + get selected() { return this._selected; } + + /** @returns {SidebarAgent|undefined} */ + getAgent(/** @type {string} */ name) { return this._agents.get(name)?.data; } + + /** @returns {string[]} */ + get agentNames() { return [...this._agents.keys()]; } + + /** @returns {boolean} */ + get hasAgents() { return this._agents.size > 0; } + + /** Reset sidebar — clears all entries + selection. */ + clear() { + for (const entry of this._agents.values()) entry.el.remove(); + this._agents.clear(); + this._selected = null; + this._sidebarEl.classList.remove("collapsed"); + } + + /** Remove an agent by name. */ + removeAgent(/** @type {string} */ name) { + const entry = this._agents.get(name); + if (entry?.el) entry.el.remove(); + this._agents.delete(name); + if (this._selected === name) this._selected = null; + } + + /** Destroy the widget. */ + destroy() { + for (const [el, evt, fn] of this._listeners) el.removeEventListener(evt, fn); + this._listeners.length = 0; + this._container.innerHTML = ""; + this._agents.clear(); + this._handlers = {}; + } + + /** + * Register an event handler. + * Events: 'agentSelected', 'editRequested', 'addRequested', 'refreshRequested' + * @param {string} event + * @param {Function} handler + */ + on(event, handler) { + (this._handlers[event] ??= []).push(handler); + } + + /* ─── Private ────────────────────────────────────────── */ + + /** @param {string} event @param {any} data */ + _emit(event, data) { + for (const fn of this._handlers[event] || []) fn(data); + } + + /** @param {EventTarget} el @param {string} evt @param {EventListener} fn */ + _on(el, evt, fn) { + el.addEventListener(evt, fn); + this._listeners.push([el, evt, fn]); + } + + /** + * @param {SidebarAgent} agent + * @returns {HTMLElement} + */ + _createEntry(agent) { + const el = document.createElement("div"); + el.className = "sidebar-agent"; + el.dataset.agent = agent.name; + el.innerHTML = ` + + + `; + + el.addEventListener("click", (e) => { + if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent-edit")) return; + this.selectAgent(agent.name); + }); + + const editBtn = el.querySelector(".sidebar-agent-edit"); + editBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this._emit("editRequested", { name: agent.name }); + }); + + return el; + } +} diff --git a/src/opensensa/web/static/styles.css b/src/opensensa/web/static/styles.css index 996681f..091d141 100644 --- a/src/opensensa/web/static/styles.css +++ b/src/opensensa/web/static/styles.css @@ -14,1341 +14,20 @@ * limitations under the License. * =========================================================================== */ -/* ── OpenSensa · Embeddable ES6 Module Styles ──────────────── */ -/* All selectors scoped under .root — no global resets */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); - -/* ── Design Tokens (scoped to .root) ─────────────── */ -.root { - /* Palette */ - --bg: #FAF8F5; - --bg-warm: #F4F0EB; - --surface: #FFFFFF; - --surface-alt: #F7F4F0; - --surface-hover: #F0ECE6; - --border: #E8E2D9; - --border-light: #F0EBE4; - --border-focus: #D4A574; - - --accent: #E8751A; - --accent-dark: #D06515; - --accent-light: #F5C9A0; - --accent-muted: #F9E0C5; - --accent-bg: #FFF7F0; - --accent-glow: rgba(232, 117, 26, .18); - - --text: #37352F; - --text-mid: #6B6760; - --text-muted: #A09B93; - --text-faint: #C4BFB7; - - --success: #3B8C5F; - --success-bg: #EDF7F0; - --warning: #C48B2C; - --error: #C4554D; - --error-bg: #FDF2F2; - --info: #5B8DC9; - - /* Layout */ - --toolbar-h: 50px; - --sidebar-w: 20%; - --radius: 16px; - --radius-sm: 12px; - --radius-xs: 8px; - --radius-pill: 100px; - - /* Type */ - --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(55, 53, 47, .04), 0 0 0 1px rgba(55, 53, 47, .04); - --shadow-card: 0 1px 4px rgba(55, 53, 47, .06), 0 0 0 1px rgba(55, 53, 47, .05); - --shadow-card-hover: 0 4px 16px rgba(55, 53, 47, .08), 0 0 0 1px rgba(55, 53, 47, .06); - --shadow-card-active: 0 6px 24px rgba(55, 53, 47, .10), 0 0 0 2px var(--accent); - --shadow-lg: 0 12px 40px rgba(55, 53, 47, .12); - - /* Root container styles */ - font-family: var(--font); - font-size: 14px; - color: var(--text); - background: var(--bg); - width: 100%; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.root *, -.root *::before, -.root *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* ── Toolbar ─────────────────────────────────────────────── */ -.toolbar { - height: var(--toolbar-h); - background: var(--surface); - border-bottom: 1px solid var(--border-light); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20px; - gap: 10px; - position: relative; - z-index: 200; - flex-shrink: 0; -} - -.toolbar-section { - display: flex; - align-items: center; - gap: 6px; -} - -.toolbar-right { - margin-left: auto; -} - -.logo { - font-size: 16px; - font-weight: 700; - color: var(--text); - display: flex; - align-items: center; - gap: 8px; - letter-spacing: -.3px; - user-select: none; -} - -.logo svg { - color: var(--accent); -} - -.tool-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - height: 32px; - min-width: 32px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - background: var(--surface); - color: var(--text-mid); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all .15s ease; -} - -.tool-btn:hover { - background: var(--surface-hover); - color: var(--text); -} - -.tool-btn-accent { - background: var(--text); - border-color: var(--text); - color: var(--surface); - font-weight: 600; -} - -.tool-btn-accent:hover { - background: #2C2B27; - border-color: #2C2B27; - color: var(--surface); -} - -/* ══════════════════════════════════════════════════════════ - App Layout: Sidebar + Chat + Tree (3-column) - ══════════════════════════════════════════════════════════ */ -.app-layout { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; - position: relative; -} - -/* ── Sidebar ─────────────────────────────────────────────── */ -.sidebar { - flex: 0 0 var(--sidebar-w); - width: var(--sidebar-w); - background: var(--surface); - border-right: 1px solid var(--border-light); - display: flex; - flex-direction: column; - overflow: hidden; - transition: flex-basis .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1), box-shadow .3s ease; -} - -.sidebar-header { - padding: 16px 18px 10px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; -} - -.sidebar-refresh-btn { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: color .2s, background .2s, transform .4s ease; -} - -.sidebar-refresh-btn:hover { - color: var(--accent); - background: var(--bg-hover); -} - -.sidebar-refresh-btn.spinning svg { - animation: spin-once .5s ease-in-out; -} - -@keyframes spin-once { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.sidebar-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .8px; - color: var(--text-muted); -} - -.sidebar-list { - flex: 1; - overflow-y: auto; - padding: 0 8px 12px; -} - -.sidebar-list::-webkit-scrollbar { - width: 4px; -} - -.sidebar-list::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -/* ── Sidebar Collapsed State ─────────────────────────────── */ -.sidebar.collapsed { - flex: 0 0 48px; - width: 48px; - position: relative; - z-index: 50; -} - -.sidebar.collapsed .sidebar-header { - padding: 14px 0 8px; - display: flex; - justify-content: center; -} - -.sidebar.collapsed .sidebar-refresh-btn { - display: none; -} - -.sidebar.collapsed .sidebar-title { - font-size: 0; - width: 20px; - height: 20px; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - opacity: 1; -} - -.sidebar.collapsed .sidebar-title::after { - content: ''; - display: block; - width: 16px; - height: 2px; - background: var(--text-muted); - border-radius: 2px; - box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); -} - -.sidebar.collapsed .sidebar-list { - display: none; -} - -/* Click-expanded overlay — stays in flex flow so chat adjusts */ -.sidebar.collapsed.expanded { - flex: 0 0 var(--sidebar-w); - width: var(--sidebar-w); - box-shadow: var(--shadow-lg); - z-index: 100; -} - -.sidebar.collapsed.expanded .sidebar-header { - padding: 16px 18px 10px; - justify-content: space-between; -} - -.sidebar.collapsed.expanded .sidebar-title { - font-size: 11px; - width: auto; - height: auto; -} - -.sidebar.collapsed.expanded .sidebar-title::after { - display: none; -} - -.sidebar.collapsed.expanded .sidebar-refresh-btn { - display: flex; -} - -.sidebar.collapsed.expanded .sidebar-list { - display: block; - padding: 0 8px 12px; -} - -.sidebar.collapsed .sidebar-header { - cursor: pointer; -} - -/* main-area is already flex:1, no override needed when sidebar collapses */ - -/* ── Sidebar Agent Entry ─────────────────────────────────── */ -.sidebar-agent { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - margin-bottom: 2px; - border-radius: var(--radius-xs); - cursor: pointer; - transition: background .2s ease, box-shadow .2s ease, transform .15s ease; - position: relative; - user-select: none; -} - -.sidebar-agent:active { - transform: scale(.98); -} - -.sidebar-agent:hover { - background: var(--surface-hover); -} - -.sidebar-agent.active { - background: var(--accent-bg); - box-shadow: inset 3px 0 0 var(--accent); - animation: sidebarSelect .3s ease; -} - -@keyframes sidebarSelect { - from { - background: var(--surface-hover); - transform: translateX(0); - } - - 40% { - transform: translateX(4px); - } - - to { - background: var(--accent-bg); - transform: translateX(0); - } -} - -.sidebar-agent.delegating { - background: var(--success-bg); -} - -.sidebar-agent-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--text-faint); - flex-shrink: 0; - margin-top: 5px; - transition: background .3s; -} - -.sidebar-agent.active .sidebar-agent-dot { - background: var(--accent); -} - -.sidebar-agent.delegating .sidebar-agent-dot { - background: var(--success); -} - -.sidebar-agent-info { - flex: 1; - min-width: 0; -} - -.sidebar-agent-name { - font-size: 13px; - font-weight: 600; - color: var(--text); - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -.15px; -} - -.sidebar-agent-desc { - font-size: 11px; - color: var(--text-muted); - display: block; - margin-top: 2px; - line-height: 1.4; -} - -.sidebar-agent-edit { - width: 24px; - height: 24px; - border: none; - background: none; - color: var(--text-faint); - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: all .12s; - flex-shrink: 0; -} - -.sidebar-agent:hover .sidebar-agent-edit { - opacity: 1; -} - -.sidebar-agent-edit:hover { - background: var(--surface-alt); - color: var(--text-mid); -} - -/* ══════════════════════════════════════════════════════════ - Centre: Chat Area - ══════════════════════════════════════════════════════════ */ -.main-area { - flex: 1 1 0; - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--bg); - position: relative; - padding: 20px 24px; - transition: flex .3s cubic-bezier(.4, 0, .2, 1); -} - -/* Empty state */ -.empty-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - color: var(--text-muted); -} - -.empty-state svg { - opacity: .2; - stroke: var(--text-faint); -} - -.empty-state p { - font-size: 15px; - font-weight: 600; - color: var(--text-mid); -} - -.empty-state span { - font-size: 13px; -} - -/* ── Primary Chat Panel ──────────────────────────────────── */ -.chat-panel { - background: var(--surface); - border-radius: var(--radius); - box-shadow: var(--shadow-card); - border: 1.5px solid var(--border-light); - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - animation: panelSlideIn .25s ease; -} - -@keyframes panelSlideIn { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.chat-panel.panel-enter { - animation: panelEnter .3s cubic-bezier(.4, 0, .2, 1); -} - -@keyframes panelEnter { - 0% { - opacity: 0; - transform: translateX(-16px) scale(.98); - } - - 100% { - opacity: 1; - transform: translateX(0) scale(1); - } -} - -.chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 20px; - border-bottom: 1px solid var(--border-light); - flex-shrink: 0; -} - -.chat-header-left { - display: flex; - align-items: center; - gap: 10px; -} - -.chat-header-right { - display: flex; - align-items: center; - gap: 4px; -} - -.chat-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - flex-shrink: 0; -} - -.chat-status-dot.thinking { - animation: dotBreathe 1.2s ease-in-out infinite; -} - -.chat-agent-name { - font-size: 15px; - font-weight: 700; - color: var(--text); - letter-spacing: -.2px; -} - -.chat-agent-model { - font-size: 10.5px; - font-weight: 600; - font-family: var(--mono); - color: var(--text-muted); - background: var(--surface-alt); - padding: 2px 7px; - border-radius: 4px; - letter-spacing: .3px; -} - -.icon-btn { - width: 30px; - height: 30px; - border: none; - background: none; - color: var(--text-muted); - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .12s; -} - -.icon-btn:hover { - background: var(--surface-alt); - color: var(--text); -} - -/* ── Chat Messages ───────────────────────────────────────── */ -.chat-messages { - flex: 1; - overflow-y: auto; - min-height: 0; -} - -.chat-messages::-webkit-scrollbar { - width: 4px; -} - -.chat-messages::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -.chat-messages-inner { - padding: 16px 20px; - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Messages */ -.chat-msg { - font-size: 13.5px; - line-height: 1.6; - max-width: 80%; - padding: 10px 14px; - border-radius: var(--radius-sm); - word-break: break-word; - animation: msgUp .2s ease; -} - -@keyframes msgUp { - from { - opacity: 0; - transform: translateY(4px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.chat-msg.user { - align-self: flex-end; - background: var(--text); - color: var(--surface); - border-bottom-right-radius: 4px; -} - -.chat-msg.agent { - align-self: flex-start; - background: var(--surface-alt); - color: var(--text); - border-bottom-left-radius: 4px; - border: 1px solid var(--border-light); -} - -.chat-msg.agent p { - margin: 0 0 .3em; -} - -.chat-msg.agent p:last-child { - margin: 0; -} - -.chat-msg.agent pre { - background: #2C2B27; - color: #E8E2D9; - padding: 10px 12px; - border-radius: 8px; - margin: 6px 0; - overflow-x: auto; - font-size: 12px; - font-family: var(--mono); -} - -.chat-msg.agent code { - font-family: var(--mono); - font-size: .88em; -} - -.chat-msg.agent :not(pre)>code { - background: rgba(55, 53, 47, .06); - padding: 1px 5px; - border-radius: 4px; -} - -.chat-msg.user :not(pre)>code { - background: rgba(255, 255, 255, .15); -} - -.chat-msg.error-msg { - align-self: center; - background: var(--error-bg); - color: var(--error); - font-size: 12.5px; - text-align: center; - border-radius: var(--radius-pill); - padding: 5px 16px; -} - -.chat-msg.system-msg { - align-self: center; - background: none; - color: var(--text-muted); - font-size: 11.5px; - text-align: center; - padding: 2px 8px; -} - -/* Thinking dots */ -.chat-thinking { - display: flex; - gap: 4px; - padding: 8px 12px; - align-self: flex-start; -} - -.chat-thinking span { - width: 6px; - height: 6px; - background: var(--accent-light); - border-radius: 50%; - animation: dots .7s ease-in-out infinite; -} - -.chat-thinking span:nth-child(2) { - animation-delay: .12s; -} - -.chat-thinking span:nth-child(3) { - animation-delay: .24s; -} - -@keyframes dots { - - 0%, - 80%, - 100% { - transform: scale(.5); - opacity: .3; - } - - 40% { - transform: scale(1); - opacity: 1; - } -} - -/* ── Chat Activity (Tool Chips) ──────────────────────────── */ -.chat-activity { - min-height: 0; - overflow: hidden; - padding: 0 20px; - transition: padding .2s, min-height .2s; -} - -.chat-activity:not(:empty) { - padding: 6px 20px; - min-height: 32px; -} - -.tool-chip { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 500; - padding: 3px 8px; - border-radius: 6px; - margin: 2px 2px; - animation: chipPop .15s ease; -} - -@keyframes chipPop { - from { - opacity: 0; - transform: scale(.95); - } - - to { - opacity: 1; - transform: scale(1); - } -} - -.tool-chip.running { - background: var(--accent-bg); - color: var(--accent); - border: 1px solid var(--accent-muted); -} - -.tool-chip.complete { - background: var(--success-bg); - color: var(--success); - border: 1px solid #C6E7D0; -} - -.tool-chip.failed { - background: var(--error-bg); - color: var(--error); - border: 1px solid #F5D0CD; -} - -.tool-chip-icon { - font-size: 11px; -} - -.spinner { - width: 9px; - height: 9px; - border: 1.5px solid var(--accent-muted); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin .65s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ── Context Headers Bar (above chat input) ──────────────── */ -.context-headers-bar { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 8px 16px; - border-top: 1px solid var(--border-light); - background: var(--bg-body); - flex-shrink: 0; -} - -.context-headers-bar.hidden { - display: none; -} - -.ctx-header-field { - display: flex; - align-items: center; - gap: 6px; - flex: 1 1 200px; - min-width: 180px; -} - -.ctx-header-label { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; -} - -.ctx-header-input { - flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - padding: 5px 10px; - font-family: var(--font); - font-size: 12.5px; - color: var(--text); - background: var(--surface); - outline: none; - transition: border-color .15s; -} - -.ctx-header-input:focus { - border-color: var(--accent); -} - -.ctx-header-input::placeholder { - color: var(--text-faint); - font-size: 11px; -} - -/* ── Chat Input ──────────────────────────────────────────── */ -.chat-input-area { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - border-top: 1px solid var(--border-light); - flex-shrink: 0; -} - -.chat-input { - flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - padding: 10px 14px; - font-family: var(--font); - font-size: 13.5px; - color: var(--text); - background: var(--surface); - outline: none; - transition: border-color .15s, box-shadow .15s; -} - -.chat-input::placeholder { - color: var(--text-faint); - font-size: 13px; -} - -.chat-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} - -.chat-send-btn { - width: 36px; - height: 36px; - border: none; - border-radius: var(--radius-xs); - background: var(--text); - color: var(--surface); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .15s; - flex-shrink: 0; -} - -.chat-send-btn svg { - width: 15px; - height: 15px; -} - -.chat-send-btn:hover { - background: #2C2B27; -} - -.chat-send-btn:disabled { - opacity: .25; - cursor: not-allowed; -} - -/* ══════════════════════════════════════════════════════════ - Link Arrow (between chat and tree) - ══════════════════════════════════════════════════════════ */ -.tree-link-arrow { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 28px; - opacity: 0; - transition: opacity .3s ease; -} - -.tree-link-arrow.visible { - opacity: 1; -} - -/* ══════════════════════════════════════════════════════════ - Right: Delegation Tree Panel - ══════════════════════════════════════════════════════════ */ -.tree-panel { - flex: 0 0 35%; - min-width: 0; - background: var(--surface); - border-left: 1px solid var(--border-light); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.tree-panel-header { - padding: 16px 18px 10px; - flex-shrink: 0; - border-bottom: 1px solid var(--border-light); -} - -.tree-panel-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .8px; - color: var(--text-muted); -} - -.tree-panel-hint { - display: block; - font-size: 11px; - color: var(--text-faint); - margin-top: 4px; - line-height: 1.4; -} - -.tree-panel .delegation-tree { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 12px 10px 20px; -} - -.tree-panel .delegation-tree::-webkit-scrollbar { - width: 4px; -} - -.tree-panel .delegation-tree::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -/* ── Delegation Tree Nodes ───────────────────────────────── */ -.delegation-tree { - display: flex; - flex-direction: column; - align-items: stretch; - position: relative; -} - -.delegation-node { - position: relative; - margin-top: 0; - padding-left: 24px; - animation: delegationSlideIn .35s cubic-bezier(.4, 0, .2, 1); -} - -@keyframes delegationSlideIn { - from { - opacity: 0; - transform: translateY(-12px) scale(.97); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* Vertical connector */ -.delegation-node::before { - content: ''; - position: absolute; - left: 12px; - top: -16px; - width: 2px; - height: 32px; - background: var(--accent-light); -} - -/* Horizontal connector */ -.delegation-node::after { - content: ''; - position: absolute; - left: 12px; - top: 16px; - width: 16px; - height: 2px; - background: var(--accent-light); -} - -.delegation-connector-arrow { - position: absolute; - left: 6px; - top: 10px; - width: 14px; - height: 14px; - color: var(--accent); - z-index: 1; -} - -/* ── Delegation mini card ────────────────────────────────── */ -.delegation-card { - background: var(--surface); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-card); - border: 1.5px solid var(--accent-muted); - width: 100%; - overflow: hidden; - transition: border-color .3s, box-shadow .3s; -} - -.delegation-card.active { - border-color: var(--accent); - box-shadow: var(--shadow-card), 0 0 0 3px var(--accent-glow); -} - -.delegation-card.completed { - border-color: var(--border-light); - opacity: .85; -} - -.delegation-card-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-bottom: 1px solid var(--border-light); - background: var(--accent-bg); -} - -.delegation-card-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - flex-shrink: 0; -} - -.delegation-card.active .delegation-card-dot { - animation: dotBreathe 1.2s ease-in-out infinite; -} - -.delegation-card.completed .delegation-card-dot { - background: var(--success); -} - -.delegation-card-name { - font-size: 12.5px; - font-weight: 700; - color: var(--text); - flex: 1; - letter-spacing: -.15px; -} - -.delegation-card-from { - font-size: 10px; - color: var(--accent-dark); - font-weight: 500; -} - -.delegation-card-messages { - padding: 10px 14px; - display: flex; - flex-direction: column; - gap: 5px; -} - -.delegation-card-messages::-webkit-scrollbar { - width: 3px; -} - -.delegation-card-messages::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; -} - -.delegation-card-activity { - padding: 0 14px; -} - -.delegation-card-activity:not(:empty) { - padding: 4px 14px 8px; -} - -.delegation-node .delegation-node { - margin-top: 0; -} - -/* ══════════════════════════════════════════════════════════ - Modal - ══════════════════════════════════════════════════════════ */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(55, 53, 47, .25); - backdrop-filter: blur(3px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - animation: fadeIn .12s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.modal { - background: var(--surface); - border-radius: var(--radius); - width: 460px; - max-height: 85vh; - overflow-y: auto; - box-shadow: var(--shadow-lg); - border: 1px solid var(--border-light); - animation: modalSlide .2s ease; -} - -@keyframes modalSlide { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 22px; - border-bottom: 1px solid var(--border-light); -} - -.modal-header h2 { - font-size: 15px; - font-weight: 700; - letter-spacing: -.2px; -} - -.modal-close { - width: 30px; - height: 30px; - border: none; - background: var(--surface-alt); - border-radius: 6px; - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .12s; -} - -.modal-close:hover { - background: var(--error-bg); - color: var(--error); -} - -.modal-body { - padding: 18px 22px; -} - -.form-group { - margin-bottom: 14px; -} - -.form-group label { - display: block; - font-size: 12.5px; - font-weight: 600; - color: var(--text); - margin-bottom: 5px; - letter-spacing: .2px; -} - -.form-hint { - font-weight: 400; - color: var(--text-muted); -} - -.form-group input, -.form-group textarea { - width: 100%; - padding: 9px 12px; - border: 1.5px solid var(--border); - border-radius: var(--radius-xs); - font-family: var(--font); - font-size: 13.5px; - color: var(--text); - background: var(--surface); - transition: border-color .15s, box-shadow .15s; -} - -.form-group input:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} - -.form-group textarea { - min-height: 80px; - resize: vertical; - line-height: 1.5; -} - -.form-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - padding-top: 6px; -} - -.btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 9px 18px; - border: none; - border-radius: var(--radius-xs); - font-family: var(--font); - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all .15s; -} - -.btn-primary { - background: var(--text); - color: var(--surface); -} - -.btn-primary:hover { - background: #2C2B27; -} - -.btn-ghost { - background: none; - color: var(--text-muted); -} - -.btn-ghost:hover { - background: var(--surface-alt); - color: var(--text); -} - -.btn-danger { - background: var(--error); - color: #fff; -} - -.btn-danger:hover { - background: #B04840; -} - -/* ── Utilities ───────────────────────────────────────────── */ -.hidden { - display: none !important; -} - -.delegation-from-label { - font-size: 10.5px; - color: var(--accent); - font-weight: 600; - padding: 2px 0; - display: flex; - align-items: center; - gap: 4px; -} \ No newline at end of file +/* ── OpenSensa · Barrel stylesheet ───────────────────────── + * + * Imports all component stylesheets in the correct order. + * Each component CSS is self-contained and documents which + * design tokens it requires (defined in tokens.css). + * + * For standalone widget usage, import only tokens.css + + * the component CSS you need (e.g. delegation-tree.css). + * ───────────────────────────────────────────────────────── */ + +@import url("css/tokens.css"); +@import url("css/toolbar.css"); +@import url("css/sidebar.css"); +@import url("css/chat.css"); +@import url("css/delegation-tree.css"); +@import url("css/modal.css"); +@import url("css/utilities.css"); diff --git a/src/opensensa/web/static/styles.css.bak b/src/opensensa/web/static/styles.css.bak new file mode 100644 index 0000000..996681f --- /dev/null +++ b/src/opensensa/web/static/styles.css.bak @@ -0,0 +1,1354 @@ +/* =========================================================================== + * Copyright (C) 2025 CapsicoHealth Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * =========================================================================== */ + +/* ── OpenSensa · Embeddable ES6 Module Styles ──────────────── */ +/* All selectors scoped under .root — no global resets */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ── Design Tokens (scoped to .root) ─────────────── */ +.root { + /* Palette */ + --bg: #FAF8F5; + --bg-warm: #F4F0EB; + --surface: #FFFFFF; + --surface-alt: #F7F4F0; + --surface-hover: #F0ECE6; + --border: #E8E2D9; + --border-light: #F0EBE4; + --border-focus: #D4A574; + + --accent: #E8751A; + --accent-dark: #D06515; + --accent-light: #F5C9A0; + --accent-muted: #F9E0C5; + --accent-bg: #FFF7F0; + --accent-glow: rgba(232, 117, 26, .18); + + --text: #37352F; + --text-mid: #6B6760; + --text-muted: #A09B93; + --text-faint: #C4BFB7; + + --success: #3B8C5F; + --success-bg: #EDF7F0; + --warning: #C48B2C; + --error: #C4554D; + --error-bg: #FDF2F2; + --info: #5B8DC9; + + /* Layout */ + --toolbar-h: 50px; + --sidebar-w: 20%; + --radius: 16px; + --radius-sm: 12px; + --radius-xs: 8px; + --radius-pill: 100px; + + /* Type */ + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(55, 53, 47, .04), 0 0 0 1px rgba(55, 53, 47, .04); + --shadow-card: 0 1px 4px rgba(55, 53, 47, .06), 0 0 0 1px rgba(55, 53, 47, .05); + --shadow-card-hover: 0 4px 16px rgba(55, 53, 47, .08), 0 0 0 1px rgba(55, 53, 47, .06); + --shadow-card-active: 0 6px 24px rgba(55, 53, 47, .10), 0 0 0 2px var(--accent); + --shadow-lg: 0 12px 40px rgba(55, 53, 47, .12); + + /* Root container styles */ + font-family: var(--font); + font-size: 14px; + color: var(--text); + background: var(--bg); + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.root *, +.root *::before, +.root *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Toolbar ─────────────────────────────────────────────── */ +.toolbar { + height: var(--toolbar-h); + background: var(--surface); + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + gap: 10px; + position: relative; + z-index: 200; + flex-shrink: 0; +} + +.toolbar-section { + display: flex; + align-items: center; + gap: 6px; +} + +.toolbar-right { + margin-left: auto; +} + +.logo { + font-size: 16px; + font-weight: 700; + color: var(--text); + display: flex; + align-items: center; + gap: 8px; + letter-spacing: -.3px; + user-select: none; +} + +.logo svg { + color: var(--accent); +} + +.tool-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 32px; + min-width: 32px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: var(--surface); + color: var(--text-mid); + font-family: var(--font); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all .15s ease; +} + +.tool-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.tool-btn-accent { + background: var(--text); + border-color: var(--text); + color: var(--surface); + font-weight: 600; +} + +.tool-btn-accent:hover { + background: #2C2B27; + border-color: #2C2B27; + color: var(--surface); +} + +/* ══════════════════════════════════════════════════════════ + App Layout: Sidebar + Chat + Tree (3-column) + ══════════════════════════════════════════════════════════ */ +.app-layout { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* ── Sidebar ─────────────────────────────────────────────── */ +.sidebar { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + background: var(--surface); + border-right: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; + transition: flex-basis .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1), box-shadow .3s ease; +} + +.sidebar-header { + padding: 16px 18px 10px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-refresh-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color .2s, background .2s, transform .4s ease; +} + +.sidebar-refresh-btn:hover { + color: var(--accent); + background: var(--bg-hover); +} + +.sidebar-refresh-btn.spinning svg { + animation: spin-once .5s ease-in-out; +} + +@keyframes spin-once { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.sidebar-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.sidebar-list { + flex: 1; + overflow-y: auto; + padding: 0 8px 12px; +} + +.sidebar-list::-webkit-scrollbar { + width: 4px; +} + +.sidebar-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ── Sidebar Collapsed State ─────────────────────────────── */ +.sidebar.collapsed { + flex: 0 0 48px; + width: 48px; + position: relative; + z-index: 50; +} + +.sidebar.collapsed .sidebar-header { + padding: 14px 0 8px; + display: flex; + justify-content: center; +} + +.sidebar.collapsed .sidebar-refresh-btn { + display: none; +} + +.sidebar.collapsed .sidebar-title { + font-size: 0; + width: 20px; + height: 20px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; +} + +.sidebar.collapsed .sidebar-title::after { + content: ''; + display: block; + width: 16px; + height: 2px; + background: var(--text-muted); + border-radius: 2px; + box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); +} + +.sidebar.collapsed .sidebar-list { + display: none; +} + +/* Click-expanded overlay — stays in flex flow so chat adjusts */ +.sidebar.collapsed.expanded { + flex: 0 0 var(--sidebar-w); + width: var(--sidebar-w); + box-shadow: var(--shadow-lg); + z-index: 100; +} + +.sidebar.collapsed.expanded .sidebar-header { + padding: 16px 18px 10px; + justify-content: space-between; +} + +.sidebar.collapsed.expanded .sidebar-title { + font-size: 11px; + width: auto; + height: auto; +} + +.sidebar.collapsed.expanded .sidebar-title::after { + display: none; +} + +.sidebar.collapsed.expanded .sidebar-refresh-btn { + display: flex; +} + +.sidebar.collapsed.expanded .sidebar-list { + display: block; + padding: 0 8px 12px; +} + +.sidebar.collapsed .sidebar-header { + cursor: pointer; +} + +/* main-area is already flex:1, no override needed when sidebar collapses */ + +/* ── Sidebar Agent Entry ─────────────────────────────────── */ +.sidebar-agent { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 2px; + border-radius: var(--radius-xs); + cursor: pointer; + transition: background .2s ease, box-shadow .2s ease, transform .15s ease; + position: relative; + user-select: none; +} + +.sidebar-agent:active { + transform: scale(.98); +} + +.sidebar-agent:hover { + background: var(--surface-hover); +} + +.sidebar-agent.active { + background: var(--accent-bg); + box-shadow: inset 3px 0 0 var(--accent); + animation: sidebarSelect .3s ease; +} + +@keyframes sidebarSelect { + from { + background: var(--surface-hover); + transform: translateX(0); + } + + 40% { + transform: translateX(4px); + } + + to { + background: var(--accent-bg); + transform: translateX(0); + } +} + +.sidebar-agent.delegating { + background: var(--success-bg); +} + +.sidebar-agent-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-faint); + flex-shrink: 0; + margin-top: 5px; + transition: background .3s; +} + +.sidebar-agent.active .sidebar-agent-dot { + background: var(--accent); +} + +.sidebar-agent.delegating .sidebar-agent-dot { + background: var(--success); +} + +.sidebar-agent-info { + flex: 1; + min-width: 0; +} + +.sidebar-agent-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -.15px; +} + +.sidebar-agent-desc { + font-size: 11px; + color: var(--text-muted); + display: block; + margin-top: 2px; + line-height: 1.4; +} + +.sidebar-agent-edit { + width: 24px; + height: 24px; + border: none; + background: none; + color: var(--text-faint); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: all .12s; + flex-shrink: 0; +} + +.sidebar-agent:hover .sidebar-agent-edit { + opacity: 1; +} + +.sidebar-agent-edit:hover { + background: var(--surface-alt); + color: var(--text-mid); +} + +/* ══════════════════════════════════════════════════════════ + Centre: Chat Area + ══════════════════════════════════════════════════════════ */ +.main-area { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg); + position: relative; + padding: 20px 24px; + transition: flex .3s cubic-bezier(.4, 0, .2, 1); +} + +/* Empty state */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} + +.empty-state svg { + opacity: .2; + stroke: var(--text-faint); +} + +.empty-state p { + font-size: 15px; + font-weight: 600; + color: var(--text-mid); +} + +.empty-state span { + font-size: 13px; +} + +/* ── Primary Chat Panel ──────────────────────────────────── */ +.chat-panel { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--border-light); + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + animation: panelSlideIn .25s ease; +} + +@keyframes panelSlideIn { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-panel.panel-enter { + animation: panelEnter .3s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes panelEnter { + 0% { + opacity: 0; + transform: translateX(-16px) scale(.98); + } + + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.chat-header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.chat-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.chat-status-dot.thinking { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +.chat-agent-name { + font-size: 15px; + font-weight: 700; + color: var(--text); + letter-spacing: -.2px; +} + +.chat-agent-model { + font-size: 10.5px; + font-weight: 600; + font-family: var(--mono); + color: var(--text-muted); + background: var(--surface-alt); + padding: 2px 7px; + border-radius: 4px; + letter-spacing: .3px; +} + +.icon-btn { + width: 30px; + height: 30px; + border: none; + background: none; + color: var(--text-muted); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.icon-btn:hover { + background: var(--surface-alt); + color: var(--text); +} + +/* ── Chat Messages ───────────────────────────────────────── */ +.chat-messages { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.chat-messages-inner { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Messages */ +.chat-msg { + font-size: 13.5px; + line-height: 1.6; + max-width: 80%; + padding: 10px 14px; + border-radius: var(--radius-sm); + word-break: break-word; + animation: msgUp .2s ease; +} + +@keyframes msgUp { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-msg.user { + align-self: flex-end; + background: var(--text); + color: var(--surface); + border-bottom-right-radius: 4px; +} + +.chat-msg.agent { + align-self: flex-start; + background: var(--surface-alt); + color: var(--text); + border-bottom-left-radius: 4px; + border: 1px solid var(--border-light); +} + +.chat-msg.agent p { + margin: 0 0 .3em; +} + +.chat-msg.agent p:last-child { + margin: 0; +} + +.chat-msg.agent pre { + background: #2C2B27; + color: #E8E2D9; + padding: 10px 12px; + border-radius: 8px; + margin: 6px 0; + overflow-x: auto; + font-size: 12px; + font-family: var(--mono); +} + +.chat-msg.agent code { + font-family: var(--mono); + font-size: .88em; +} + +.chat-msg.agent :not(pre)>code { + background: rgba(55, 53, 47, .06); + padding: 1px 5px; + border-radius: 4px; +} + +.chat-msg.user :not(pre)>code { + background: rgba(255, 255, 255, .15); +} + +.chat-msg.error-msg { + align-self: center; + background: var(--error-bg); + color: var(--error); + font-size: 12.5px; + text-align: center; + border-radius: var(--radius-pill); + padding: 5px 16px; +} + +.chat-msg.system-msg { + align-self: center; + background: none; + color: var(--text-muted); + font-size: 11.5px; + text-align: center; + padding: 2px 8px; +} + +/* Thinking dots */ +.chat-thinking { + display: flex; + gap: 4px; + padding: 8px 12px; + align-self: flex-start; +} + +.chat-thinking span { + width: 6px; + height: 6px; + background: var(--accent-light); + border-radius: 50%; + animation: dots .7s ease-in-out infinite; +} + +.chat-thinking span:nth-child(2) { + animation-delay: .12s; +} + +.chat-thinking span:nth-child(3) { + animation-delay: .24s; +} + +@keyframes dots { + + 0%, + 80%, + 100% { + transform: scale(.5); + opacity: .3; + } + + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ── Chat Activity (Tool Chips) ──────────────────────────── */ +.chat-activity { + min-height: 0; + overflow: hidden; + padding: 0 20px; + transition: padding .2s, min-height .2s; +} + +.chat-activity:not(:empty) { + padding: 6px 20px; + min-height: 32px; +} + +.tool-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 500; + padding: 3px 8px; + border-radius: 6px; + margin: 2px 2px; + animation: chipPop .15s ease; +} + +@keyframes chipPop { + from { + opacity: 0; + transform: scale(.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +.tool-chip.running { + background: var(--accent-bg); + color: var(--accent); + border: 1px solid var(--accent-muted); +} + +.tool-chip.complete { + background: var(--success-bg); + color: var(--success); + border: 1px solid #C6E7D0; +} + +.tool-chip.failed { + background: var(--error-bg); + color: var(--error); + border: 1px solid #F5D0CD; +} + +.tool-chip-icon { + font-size: 11px; +} + +.spinner { + width: 9px; + height: 9px; + border: 1.5px solid var(--accent-muted); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .65s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ── Context Headers Bar (above chat input) ──────────────── */ +.context-headers-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--border-light); + background: var(--bg-body); + flex-shrink: 0; +} + +.context-headers-bar.hidden { + display: none; +} + +.ctx-header-field { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 200px; + min-width: 180px; +} + +.ctx-header-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.ctx-header-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 5px 10px; + font-family: var(--font); + font-size: 12.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s; +} + +.ctx-header-input:focus { + border-color: var(--accent); +} + +.ctx-header-input::placeholder { + color: var(--text-faint); + font-size: 11px; +} + +/* ── Chat Input ──────────────────────────────────────────── */ +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-light); + flex-shrink: 0; +} + +.chat-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 10px 14px; + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + outline: none; + transition: border-color .15s, box-shadow .15s; +} + +.chat-input::placeholder { + color: var(--text-faint); + font-size: 13px; +} + +.chat-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.chat-send-btn { + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius-xs); + background: var(--text); + color: var(--surface); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .15s; + flex-shrink: 0; +} + +.chat-send-btn svg { + width: 15px; + height: 15px; +} + +.chat-send-btn:hover { + background: #2C2B27; +} + +.chat-send-btn:disabled { + opacity: .25; + cursor: not-allowed; +} + +/* ══════════════════════════════════════════════════════════ + Link Arrow (between chat and tree) + ══════════════════════════════════════════════════════════ */ +.tree-link-arrow { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + opacity: 0; + transition: opacity .3s ease; +} + +.tree-link-arrow.visible { + opacity: 1; +} + +/* ══════════════════════════════════════════════════════════ + Right: Delegation Tree Panel + ══════════════════════════════════════════════════════════ */ +.tree-panel { + flex: 0 0 35%; + min-width: 0; + background: var(--surface); + border-left: 1px solid var(--border-light); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.tree-panel-header { + padding: 16px 18px 10px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-light); +} + +.tree-panel-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-muted); +} + +.tree-panel-hint { + display: block; + font-size: 11px; + color: var(--text-faint); + margin-top: 4px; + line-height: 1.4; +} + +.tree-panel .delegation-tree { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 12px 10px 20px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar { + width: 4px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +/* ── Delegation Tree Nodes ───────────────────────────────── */ +.delegation-tree { + display: flex; + flex-direction: column; + align-items: stretch; + position: relative; +} + +.delegation-node { + position: relative; + margin-top: 0; + padding-left: 24px; + animation: delegationSlideIn .35s cubic-bezier(.4, 0, .2, 1); +} + +@keyframes delegationSlideIn { + from { + opacity: 0; + transform: translateY(-12px) scale(.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Vertical connector */ +.delegation-node::before { + content: ''; + position: absolute; + left: 12px; + top: -16px; + width: 2px; + height: 32px; + background: var(--accent-light); +} + +/* Horizontal connector */ +.delegation-node::after { + content: ''; + position: absolute; + left: 12px; + top: 16px; + width: 16px; + height: 2px; + background: var(--accent-light); +} + +.delegation-connector-arrow { + position: absolute; + left: 6px; + top: 10px; + width: 14px; + height: 14px; + color: var(--accent); + z-index: 1; +} + +/* ── Delegation mini card ────────────────────────────────── */ +.delegation-card { + background: var(--surface); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-card); + border: 1.5px solid var(--accent-muted); + width: 100%; + overflow: hidden; + transition: border-color .3s, box-shadow .3s; +} + +.delegation-card.active { + border-color: var(--accent); + box-shadow: var(--shadow-card), 0 0 0 3px var(--accent-glow); +} + +.delegation-card.completed { + border-color: var(--border-light); + opacity: .85; +} + +.delegation-card-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-light); + background: var(--accent-bg); +} + +.delegation-card-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +.delegation-card.active .delegation-card-dot { + animation: dotBreathe 1.2s ease-in-out infinite; +} + +.delegation-card.completed .delegation-card-dot { + background: var(--success); +} + +.delegation-card-name { + font-size: 12.5px; + font-weight: 700; + color: var(--text); + flex: 1; + letter-spacing: -.15px; +} + +.delegation-card-from { + font-size: 10px; + color: var(--accent-dark); + font-weight: 500; +} + +.delegation-card-messages { + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.delegation-card-messages::-webkit-scrollbar { + width: 3px; +} + +.delegation-card-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.delegation-card-activity { + padding: 0 14px; +} + +.delegation-card-activity:not(:empty) { + padding: 4px 14px 8px; +} + +.delegation-node .delegation-node { + margin-top: 0; +} + +/* ══════════════════════════════════════════════════════════ + Modal + ══════════════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(55, 53, 47, .25); + backdrop-filter: blur(3px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn .12s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal { + background: var(--surface); + border-radius: var(--radius); + width: 460px; + max-height: 85vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + border: 1px solid var(--border-light); + animation: modalSlide .2s ease; +} + +@keyframes modalSlide { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + border-bottom: 1px solid var(--border-light); +} + +.modal-header h2 { + font-size: 15px; + font-weight: 700; + letter-spacing: -.2px; +} + +.modal-close { + width: 30px; + height: 30px; + border: none; + background: var(--surface-alt); + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all .12s; +} + +.modal-close:hover { + background: var(--error-bg); + color: var(--error); +} + +.modal-body { + padding: 18px 22px; +} + +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + font-size: 12.5px; + font-weight: 600; + color: var(--text); + margin-bottom: 5px; + letter-spacing: .2px; +} + +.form-hint { + font-weight: 400; + color: var(--text-muted); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border); + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13.5px; + color: var(--text); + background: var(--surface); + transition: border-color .15s, box-shadow .15s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.form-group textarea { + min-height: 80px; + resize: vertical; + line-height: 1.5; +} + +.form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 6px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 9px 18px; + border: none; + border-radius: var(--radius-xs); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all .15s; +} + +.btn-primary { + background: var(--text); + color: var(--surface); +} + +.btn-primary:hover { + background: #2C2B27; +} + +.btn-ghost { + background: none; + color: var(--text-muted); +} + +.btn-ghost:hover { + background: var(--surface-alt); + color: var(--text); +} + +.btn-danger { + background: var(--error); + color: #fff; +} + +.btn-danger:hover { + background: #B04840; +} + +/* ── Utilities ───────────────────────────────────────────── */ +.hidden { + display: none !important; +} + +.delegation-from-label { + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + padding: 2px 0; + display: flex; + align-items: center; + gap: 4px; +} \ No newline at end of file From d23df0bd9b1db2ceca591ca2b3f43694312b2c50 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Fri, 20 Mar 2026 08:56:47 -0400 Subject: [PATCH 08/13] mermaid fix --- architecture.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/architecture.md b/architecture.md index 98f3f1b..94dad70 100644 --- a/architecture.md +++ b/architecture.md @@ -1,3 +1,6 @@ +# OpenSensa Architecture + +```mermaid flowchart TD subgraph Entry["Entry Points"] CLI["CLI: opensensa chat
cli.py"] @@ -102,3 +105,4 @@ flowchart TD style Delegation fill:#ffccbc,stroke:#d84315 style Observability fill:#e0e0e0,stroke:#616161 style Output fill:#c8e6c9,stroke:#388e3c +``` From ba3a5136f0843c7b67c39758b992d40a8cef9c97 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Fri, 20 Mar 2026 11:32:10 -0400 Subject: [PATCH 09/13] removed dead and duplicate code, chat restructure --- README.md | 32 +- architecture.md | 30 +- src/opensensa/a2a/client.py | 122 ------ src/opensensa/cli.py | 30 +- src/opensensa/config.py | 6 + src/opensensa/framework_tools/__init__.py | 4 +- src/opensensa/framework_tools/delegate.py | 175 ++++---- .../framework_tools/discover_agents.py | 2 +- .../framework_tools/send_to_agent.py | 148 ------- src/opensensa/interactive/chat.py | 11 +- src/opensensa/orchestrator/agent_builder.py | 37 +- src/opensensa/orchestrator/server.py | 9 +- src/opensensa/web/call_graph.py | 404 ------------------ src/opensensa/web/chat_manager.py | 321 -------------- src/opensensa/web/routes.py | 89 +--- src/opensensa/web/static/app.js | 81 +--- src/opensensa/web/static/css/chat.css | 164 +++++-- .../web/static/css/delegation-tree.css | 75 +++- src/opensensa/web/static/css/modal.css | 36 +- src/opensensa/web/static/css/sidebar.css | 84 +++- src/opensensa/web/static/css/tokens.css | 2 +- src/opensensa/web/static/css/toolbar.css | 2 +- src/opensensa/web/static/css/utilities.css | 2 +- .../web/static/modules/agent-modal.js | 16 +- src/opensensa/web/static/modules/api.js | 10 - .../web/static/modules/chat-panel.js | 262 +++++++++--- src/opensensa/web/static/modules/sidebar.js | 2 +- src/opensensa/web/static/styles.css | 2 +- tests/test_framework_tools.py | 107 +++-- 29 files changed, 770 insertions(+), 1495 deletions(-) delete mode 100644 src/opensensa/a2a/client.py delete mode 100644 src/opensensa/framework_tools/send_to_agent.py delete mode 100644 src/opensensa/web/call_graph.py delete mode 100644 src/opensensa/web/chat_manager.py diff --git a/README.md b/README.md index 9540340..e831e1c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Most agent frameworks require you to learn complex SDKs, write pages of boilerpl | **Tool integration is fragmented** | Tools are served over **[MCP](https://modelcontextprotocol.io/)** (Model Context Protocol). Write a Python function, decorate it, drop the file in a folder. Done. | | **Locked to one LLM provider** | Works with **any OpenAI-compatible endpoint** — OpenAI, Ollama, vLLM, LM Studio, Together, Groq, Fireworks, and more. Switch models by editing one line in YAML. | | **No observability out of the box** | Built-in **live call graph** in the terminal, **structured JSON logging**, and a **web UI** with real-time delegation tree visualization. | -| **Hard to go from prototype to production** | `opensensa chat` for development, `opensensa serve` for production. Same architecture, same agents, same tools. | +| **Hard to go from prototype to production** | `uv run opensensa chat` for development, `uv run opensensa serve` for production. Same architecture, same agents, same tools. | ## Key Features @@ -33,7 +33,7 @@ Most agent frameworks require you to learn complex SDKs, write pages of boilerpl - **Built-in Agent Manager** — Ships with a meta-agent that can create, edit, and delete other agents conversationally. Start a project, chat with the Agent Manager, and build your agent fleet without touching a file. - **Explicit Delegation Graph** — Agents declare `sub_agents` in frontmatter. You can see exactly which agents can talk to which just by reading the `.md` files. Depth-limited (max 5 hops) to prevent infinite loops. - **Live Call Graph** — Real-time Rich terminal tree showing tool calls (🔧), delegations (🤖), and LLM invocations (💬) with timing and token counts as they happen. -- **Web UI** — `opensensa serve --web` provides a browser-based chat interface with an agent sidebar, real-time delegation tree visualization, and agent CRUD. +- **Web UI** — `uv run opensensa serve` provides a browser-based chat interface with an agent sidebar, real-time delegation tree visualization, and agent CRUD. - **Auto-Discovery** — Tools are auto-loaded from a directory; agents are scanned from the filesystem. Drop a file in, it's live — no restart needed. - **Any LLM** — Single `OpenAIChatCompletionsModel` path works with any provider exposing `/v1/chat/completions`. - **Structured Tracing** — Every LLM call, tool invocation, and delegation is logged as structured JSON with timing and token usage. @@ -46,7 +46,7 @@ Most agent frameworks require you to learn complex SDKs, write pages of boilerpl mkdir my-project && cd my-project uv init uv add opensensa -opensensa init . +uv run opensensa init . ``` This gives you a project with your own `pyproject.toml` and `uv.lock`, plus the OpenSensa scaffold: @@ -122,7 +122,7 @@ No API key needed — just point `base_url` at your local endpoint. ### Start Chatting ```bash -opensensa chat +uv run opensensa chat ``` This starts the MCP + A2A servers in the background, presents an agent picker, and drops you into an interactive Rich terminal session. All agents are live and can call each other over A2A. @@ -130,7 +130,7 @@ This starts the MCP + A2A servers in the background, presents an agent picker, a ### Run as a Service ```bash -opensensa serve +uv run opensensa serve ``` Headless mode — starts MCP tool server + A2A agent server. Each agent gets its own endpoint: @@ -329,15 +329,15 @@ logging: | Command | Description | |---|---| -| `opensensa init [dir]` | Scaffold a new project with config, agents, and sample tools | -| `opensensa chat [agent-name]` | Interactive Rich TUI — starts servers, opens a conversation | -| `opensensa serve` | Start MCP + A2A servers in headless mode | -| `opensensa serve --web` | Headless mode with browser-based web UI at `/web` | -| `opensensa add-tool ` | Generate a tool skeleton in `tools/` | -| `opensensa add-agent ` | Generate an agent skeleton in `agents/` | -| `opensensa list-tools` | List all registered MCP tools | -| `opensensa list-agents` | List all agents (local + remote) | -| `opensensa test` | Smoke test — validates config, agents, and tools | +| `uv run opensensa init [dir]` | Scaffold a new project with config, agents, and sample tools | +| `uv run opensensa chat [agent-name]` | Interactive Rich TUI — starts servers, opens a conversation | +| `uv run opensensa serve` | Start MCP + A2A servers in headless mode | +| `uv run opensensa serve --web` | Headless mode with browser-based web UI at `/web` | +| `uv run opensensa add-tool ` | Generate a tool skeleton in `tools/` | +| `uv run opensensa add-agent ` | Generate an agent skeleton in `agents/` | +| `uv run opensensa list-tools` | List all registered MCP tools | +| `uv run opensensa list-agents` | List all agents (local + remote) | +| `uv run opensensa test` | Smoke test — validates config, agents, and tools | ## Architecture @@ -383,7 +383,7 @@ Because OpenSensa agents are A2A-compliant servers, they interoperate with any A ## Web UI -Run `opensensa serve --web` and open `http://localhost:8000/web`: +Run `uv run opensensa serve` and open `http://localhost:8000/web`: - **Agent sidebar** — compact cards for all agents, click to start a chat - **Real-time chat** — streaming responses with tool call animations @@ -487,7 +487,7 @@ OpenSensa is a **local-first framework** — it runs on your machine, and you co - **API keys are your responsibility.** Store keys in `.env` files (gitignored by default) or environment variables — never hard-code them in `opensensa.yaml` or agent files. - **Tools execute arbitrary code.** Custom tools in your `tools/` directory run with the same permissions as the OpenSensa process. Only run tools you trust. Review any third-party tool code before adding it to your project. - **Agent-generated code is not sandboxed.** If an agent or tool generates and executes code, it runs with full local permissions. Exercise caution with tools that perform file system operations, network calls, or shell commands. -- **Network exposure in serve mode.** `opensensa serve` binds to `0.0.0.0` by default, exposing agent endpoints on your network. For local-only use, set `server.host: 127.0.0.1` in `opensensa.yaml`. There is no built-in authentication — do not expose to the public internet without adding your own auth layer (reverse proxy, API gateway, etc.). +- **Network exposure in serve mode.** `uv run opensensa serve` binds to `0.0.0.0` by default, exposing agent endpoints on your network. For local-only use, set `server.host: 127.0.0.1` in `opensensa.yaml`. There is no built-in authentication — do not expose to the public internet without adding your own auth layer (reverse proxy, API gateway, etc.). ### Cost diff --git a/architecture.md b/architecture.md index 94dad70..bd377f9 100644 --- a/architecture.md +++ b/architecture.md @@ -4,23 +4,22 @@ flowchart TD subgraph Entry["Entry Points"] CLI["CLI: opensensa chat
cli.py"] - WEB["Web UI: POST /api/chat/.../messages
web/routes.py"] + WEB["Web UI: POST /agents/{name}/
browser → A2A message/stream
Client-side history"] A2A["A2A: POST /agents/{name}/
orchestrator/server.py"] end subgraph Config["Configuration"] - CFG["load_config()
config.py
Loads opensensa.yaml"] + CFG["load_config()
config.py
Loads opensensa.yaml
ServerConfig.local_base_url"] REG["AgentRegistry
orchestrator/agent_registry.py
Scans agents/*.md → AgentDefinition"] end subgraph Session["Session Layer"] CS["ChatSession (CLI)
interactive/chat.py
Spawns MCP + A2A servers
Manages conversation history"] - CM["ChatManager (Web)
web/chat_manager.py
Creates sessions, SSE streaming"] - EX["FrameworkAgentExecutor
a2a/executor.py
Bridges JSON-RPC → Runner"] + EX["FrameworkAgentExecutor
a2a/executor.py
Bridges JSON-RPC → Runner
Supports framework:history for multi-turn"] end subgraph Build["Agent Construction"] - BA["build_agent()
orchestrator/agent_builder.py
• Resolves model via models.py
• Connects MCPServerStreamableHttp
• Applies tool filter
• Adds delegate FunctionTool"] + BA["build_agent()
orchestrator/agent_builder.py
• Resolves model via models.py
• Connects MCPServerStreamableHttp
• Applies tool filter
• Always adds delegate FunctionTool"] end subgraph Running["Execution Loop"] @@ -30,41 +29,39 @@ flowchart TD subgraph Tools["Tool Invocation (MCP)"] MCP["MCP Server (port 8001)
mcp_server/server.py
FastMCP, streamable-http"] TL["tool_loader.py
discover_and_load_tools()
Scans tools/*.py"] - FT["Framework Tools
framework_tools/
discover_agents, send_to_agent,
create/edit/delete_agent, list_tools"] + FT["Framework Tools
framework_tools/
discover_agents,
create/edit/delete_agent, list_tools"] UT["User Tools
tools/
add_numbers, csv_formatter,
generate_visualization"] end subgraph Delegation["Agent Delegation (A2A)"] - DT["delegate FunctionTool
framework_tools/delegate.py
Native tool (NOT on MCP)
Checks depth < 5, allowlist"] - A2AC["A2A HTTP Call
POST /agents/{sub_agent}/
JSON-RPC message/stream
X-A2A-Depth header"] + DT["delegate FunctionTool
framework_tools/delegate.py
Native tool (NOT on MCP)
By name or URL
Checks depth < 5, allowlist if sub_agents"] + A2AC["A2A HTTP Call
POST /agents/{agent}/
JSON-RPC message/stream only
X-A2A-Depth header"] end subgraph Observability["Observability"] CG["CallGraph (CLI)
interactive/call_graph.py
Rich Live tree display"] - WCG["WebCallGraph (Web)
web/call_graph.py
SSE event stream"] + A2ACG["A2ACallGraphAdapter
a2a/executor.py
Emits framework:* metadata
in A2A SSE stream"] TR["AgentTraceContext
orchestrator/tracing.py
Span tree, token usage"] - HK["RunHooks
_ChatHooks / _WebHooks
Tool/LLM lifecycle events"] + HK["RunHooks
_ChatHooks (CLI)
Tool/LLM lifecycle events"] end subgraph Output["Response"] UI["print_agent_response()
interactive/ui.py
Rich markdown panels"] - SSE["SSE Stream
Browser receives events"] + SSE["A2A SSE Stream
Browser parses artifact-update
+ framework:* status events
Token-by-token streaming"] A2AR["A2A Response
JSON-RPC result + artifacts"] end %% Entry → Config CLI --> CFG - WEB --> CFG CFG --> REG %% Entry → Session CLI --> CS - WEB --> CM + WEB --> EX A2A --> EX %% Session → Build CS --> BA - CM --> BA EX --> BA %% Build → Run @@ -84,15 +81,14 @@ flowchart TD %% Observability CS -.-> CG CS -.-> TR - CM -.-> WCG + EX -.-> A2ACG RUN -.-> HK HK -.-> CG - HK -.-> WCG HK -.-> TR %% Output RUN -- "CLI final_output" --> UI - RUN -- "Web SSE stream" --> SSE + RUN -- "A2A SSE stream" --> SSE RUN -- "A2A artifacts" --> A2AR %% Styling diff --git a/src/opensensa/a2a/client.py b/src/opensensa/a2a/client.py deleted file mode 100644 index 6218bca..0000000 --- a/src/opensensa/a2a/client.py +++ /dev/null @@ -1,122 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""A2A client — sends SendMessage to remote agents. - -Used by the send_to_agent framework tool. Supports both raw httpx calls -and (when available) the a2a-sdk client for richer type-safety. -""" - -import logging -import uuid -from typing import Any, Optional - -import httpx - -from a2a.types import ( - AgentCard, - Message, - MessageSendParams, - Role, - SendMessageRequest, - TextPart, -) - -logger = logging.getLogger("opensensa.a2a") - - -async def fetch_agent_card(agent_url: str) -> AgentCard: - """Fetch an A2A Agent Card from a remote agent's well-known URL. - - Args: - agent_url: Base URL of the agent (e.g. http://localhost:9000). - - Returns: - The Agent Card as an a2a-sdk AgentCard model. - """ - url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json" - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(url) - resp.raise_for_status() - return AgentCard.model_validate(resp.json()) - - -async def fetch_agent_card_dict(agent_url: str) -> dict[str, Any]: - """Fetch an A2A Agent Card as a raw dict (for framework tools). - - Args: - agent_url: Base URL of the agent. - - Returns: - The Agent Card as a dict. - """ - url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json" - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(url) - resp.raise_for_status() - return resp.json() - - -async def send_message( - agent_url: str, - message: str, - task_id: Optional[str] = None, - context_id: Optional[str] = None, - timeout: float = 120.0, - metadata: Optional[dict[str, Any]] = None, -) -> dict[str, Any]: - """Send an A2A SendMessage JSON-RPC request to a remote agent. - - Args: - agent_url: Base URL of the agent. - message: Text message to send. - task_id: Optional task ID for multi-turn. - context_id: Optional context ID for conversation threading. - timeout: Request timeout in seconds. - metadata: Optional metadata to include in the request. - - Returns: - JSON-RPC result dict. - """ - # POST to the agent's root — the JSON-RPC endpoint for per-agent sub-apps - url = f"{agent_url.rstrip('/')}/" - - msg_id = str(uuid.uuid4()) - a2a_message: dict[str, Any] = { - "role": "user", - "parts": [{"kind": "text", "text": message}], - "messageId": msg_id, - } - if task_id: - a2a_message["taskId"] = task_id - if context_id: - a2a_message["contextId"] = context_id - - params: dict[str, Any] = {"message": a2a_message} - if metadata: - params["metadata"] = metadata - - payload = { - "jsonrpc": "2.0", - "id": str(uuid.uuid4()), - "method": "message/send", - "params": params, - } - - async with httpx.AsyncClient(timeout=timeout) as client: - resp = await client.post(url, json=payload, headers={"Content-Type": "application/json"}) - resp.raise_for_status() - return resp.json() diff --git a/src/opensensa/cli.py b/src/opensensa/cli.py index e802d28..ef7e0c6 100644 --- a/src/opensensa/cli.py +++ b/src/opensensa/cli.py @@ -134,6 +134,19 @@ def init(directory: str): ) click.echo(f" [created] .env") + # --- Ensure .env is in .gitignore --- + gitignore_file = project_dir / ".gitignore" + env_entry = ".env" + needs_add = True + if gitignore_file.exists(): + lines = gitignore_file.read_text(encoding="utf-8").splitlines() + # Check for an explicit .env entry (not .env/ or .env* which mean something else) + needs_add = not any(line.strip() == env_entry for line in lines) + if needs_add: + with gitignore_file.open("a", encoding="utf-8") as f: + f.write("\n# OpenSensa — never commit secrets\n.env\n") + click.echo(f" [updated] .gitignore — added .env") + click.echo() click.echo("Done! Next steps:") click.echo(f" cd {project_dir}") @@ -215,8 +228,7 @@ def _start_orchestrator(config, reload: bool = False, enable_web: bool = True): for name in registry.agent_names(): click.echo(f" /agents/{name}/") if enable_web: - advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host - click.echo(f" Web UI: http://{advertise_host}:{config.server.orchestrator_port}/web") + click.echo(f" Web UI: {config.server.local_base_url}/web") app = create_orchestrator_app(config, registry, mcp_server_url=mcp_server_url, enable_web=enable_web) @@ -245,8 +257,7 @@ def _start_both(config, reload: bool = False, enable_web: bool = True): for name in agent_names: click.echo(f" /agents/{name}/") if enable_web: - advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host - click.echo(f" Web UI: http://{advertise_host}:{config.server.orchestrator_port}/web") + click.echo(f" Web UI: {config.server.local_base_url}/web") click.echo() mcp_proc = multiprocessing.Process( @@ -552,22 +563,15 @@ def _register_framework_tools(mcp, config): remote_agents = [{"url": ra.url} for ra in config.remote_agents] - # Build local base URL so discover_agents can return reachable URLs - advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host - local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" - # Import and register each framework tool - from opensensa.framework_tools import discover_agents, send_to_agent, create_agent, edit_agent, delete_agent, list_tools + from opensensa.framework_tools import discover_agents, create_agent, edit_agent, delete_agent, list_tools discover_agents.register( mcp, agent_registry=registry, remote_agents=remote_agents, - local_base_url=local_base_url, + local_base_url=config.server.local_base_url, ) - send_to_agent.register(mcp) - # NOTE: delegate is NOT registered on MCP — it is a native FunctionTool - # wired directly into the Agent by agent_builder.py (A2A, not MCP). create_agent.register(mcp, agents_directory=config.agents.directory) edit_agent.register(mcp, agents_directory=config.agents.directory) delete_agent.register(mcp, agents_directory=config.agents.directory) diff --git a/src/opensensa/config.py b/src/opensensa/config.py index c9697ad..facf787 100644 --- a/src/opensensa/config.py +++ b/src/opensensa/config.py @@ -46,6 +46,12 @@ class ServerConfig(BaseModel): orchestrator_port: int = 8000 mcp_port: int = 8001 + @property + def local_base_url(self) -> str: + """Base URL for reaching the local A2A server (e.g. from delegate, discover_agents).""" + host = "localhost" if self.host == "0.0.0.0" else self.host + return f"http://{host}:{self.orchestrator_port}" + class AgentsConfig(BaseModel): """Agents directory settings.""" directory: str = "./agents/" diff --git a/src/opensensa/framework_tools/__init__.py b/src/opensensa/framework_tools/__init__.py index 8c8ed60..986114a 100644 --- a/src/opensensa/framework_tools/__init__.py +++ b/src/opensensa/framework_tools/__init__.py @@ -16,9 +16,9 @@ """Framework-provided tools. -MCP tools (registered on the MCP server): discover_agents, send_to_agent, +MCP tools (registered on the MCP server): discover_agents, create_agent, edit_agent, delete_agent, list_tools. Native tools (attached directly to Agent objects): delegate — uses A2A -for agent-to-agent communication, bypassing MCP entirely. +for agent-to-agent communication (by name or URL), bypassing MCP entirely. """ diff --git a/src/opensensa/framework_tools/delegate.py b/src/opensensa/framework_tools/delegate.py index 77b2e7f..5482c2d 100644 --- a/src/opensensa/framework_tools/delegate.py +++ b/src/opensensa/framework_tools/delegate.py @@ -270,10 +270,11 @@ async def _try_stream_delegation( async def _delegate_impl( - agent_name: str, message: str, *, - allowed_sub_agents: list[str], + agent_name: str | None = None, + agent_url: str | None = None, + allowed_sub_agents: list[str] | None = None, from_agent_name: str = "unknown", agent_registry=None, local_base_url: str = "http://localhost:8000", @@ -283,12 +284,22 @@ async def _delegate_impl( client_request_id: str | None = None, context_headers: dict[str, str] | None = None, ) -> str: - """Core delegation logic — sends an A2A ``message/send`` to a sub-agent. + """Core delegation logic — sends an A2A request to another agent. + + Accepts either ``agent_name`` (resolved via registry) or ``agent_url`` + (used directly). When ``agent_name`` is provided and the caller has a + ``sub_agents`` allowlist, the name is validated against it. Returns a JSON string (the Agents SDK expects function tools to return str). """ - # Validate that the target agent is in the caller's sub_agents list - if agent_name not in allowed_sub_agents: + if not agent_name and not agent_url: + return json.dumps({ + "status": "error", + "error": "Either 'agent_name' or 'agent_url' must be provided.", + }) + + # When using name-based delegation with an allowlist, enforce it + if agent_name and allowed_sub_agents and agent_name not in allowed_sub_agents: return json.dumps({ "status": "error", "error": ( @@ -299,9 +310,10 @@ async def _delegate_impl( # Enforce depth limit if current_depth >= MAX_DELEGATION_DEPTH: + target = agent_name or agent_url logger.warning( f"Delegation depth limit reached ({current_depth}/{MAX_DELEGATION_DEPTH}). " - f"Refusing to delegate to '{agent_name}'." + f"Refusing to delegate to '{target}'." ) return json.dumps({ "status": "error", @@ -312,24 +324,32 @@ async def _delegate_impl( ), }) - # Resolve agent name → URL - agent_url = _resolve_agent_url( - agent_name, - agent_registry=agent_registry, - local_base_url=local_base_url, - remote_agents=remote_agents, - ) - if not agent_url: - return json.dumps({ - "status": "error", - "error": ( - f"Agent '{agent_name}' not found. " - f"Make sure it exists as a local or remote agent." - ), - }) + # Resolve target — either from name or direct URL + if agent_url: + resolved_url = agent_url.rstrip("/") + # Derive a display name from the URL if no name given + if not agent_name: + agent_name = resolved_url.rstrip("/").split("/")[-1] or "remote-agent" + else: + resolved_url_or_none = _resolve_agent_url( + agent_name, + agent_registry=agent_registry, + local_base_url=local_base_url, + remote_agents=remote_agents, + ) + if not resolved_url_or_none: + return json.dumps({ + "status": "error", + "error": ( + f"Agent '{agent_name}' not found. " + f"Make sure it exists as a local or remote agent, " + f"or provide an agent_url instead." + ), + }) + resolved_url = resolved_url_or_none # Build A2A request parameters - a2a_endpoint = f"{agent_url.rstrip('/')}/" + a2a_endpoint = f"{resolved_url}/" # Immediately show the delegation target in the call graph if call_graph is not None: @@ -362,7 +382,7 @@ async def _delegate_impl( if client_request_id: headers["X-A2A-Client-Request-Id"] = client_request_id - # ---- Try streaming first for real-time sub-agent visibility ---- + # ---- Stream delegation via A2A message/stream ---- try: stream_tools, stream_result = await _try_stream_delegation( a2a_endpoint, params, headers, @@ -381,53 +401,6 @@ async def _delegate_impl( call_graph, agent_name, response=delegate_response ) return json.dumps(response) - except Exception as exc: - logger.warning( - f"Streaming delegation to '{agent_name}' failed ({type(exc).__name__}: {exc}), " - "falling back to sync message/send" - ) - - # ---- Sync fallback (message/send) ---- - payload = { - "jsonrpc": "2.0", - "id": client_request_id or str(uuid.uuid4()), - "method": "message/send", - "params": params, - } - - try: - async with httpx.AsyncClient(timeout=_A2A_TIMEOUT) as client: - resp = await client.post(a2a_endpoint, json=payload, headers=headers) - resp.raise_for_status() - result = resp.json() - - if "error" in result: - return json.dumps({"status": "error", "error": result["error"]}) - - # Extract tool call trace from execution-trace artifact if present - tools_used: list[dict] = [] - task_result = result.get("result", {}) - for artifact in task_result.get("artifacts", []): - aname = artifact.get("name", "") - if aname.endswith("-execution-trace"): - for part in artifact.get("parts", []): - if part.get("kind") == "data": - data = part.get("data", {}) - tools_used = data.get("tools_used", []) - - response: dict[str, Any] = { - "status": "success", - "agent": agent_name, - "result": task_result, - } - if tools_used: - response["tools_used"] = tools_used - - delegate_response = _extract_response_text(task_result) - await _notify_delegation_end( - call_graph, agent_name, response=delegate_response - ) - return json.dumps(response) except httpx.TimeoutException: await _notify_delegation_end( @@ -459,10 +432,20 @@ def build_delegate_tool( context_headers: dict[str, str] | None = None, current_depth: int = 0, ): - """Build a native ``FunctionTool`` for delegating to sub-agents. + """Build a native ``FunctionTool`` for delegating to other agents. + + Supports two modes: + + * **By name** — provide ``agent_name``. If the calling agent declares + ``sub_agents``, the name must be in that allowlist. Resolved to a URL + via the local ``AgentRegistry`` or ``remote_agents`` config. + * **By URL** — provide ``agent_url``. Used for ad-hoc communication + with agents discovered at runtime (e.g. via ``discover_agents``). + + Both modes share the same streaming, call-graph, and depth-limit logic. Args: - agent_def: The calling agent's ``AgentDefinition`` (used to read ``sub_agents``). + agent_def: The calling agent's ``AgentDefinition``. agent_registry: ``AgentRegistry`` for resolving local agent names. local_base_url: Base URL of the local A2A server. remote_agents: List of ``{"url": "...", "name": "..."}`` for remote agents. @@ -475,15 +458,16 @@ def build_delegate_tool( """ from agents import FunctionTool - allowed = list(agent_def.sub_agents) + allowed = list(agent_def.sub_agents) if agent_def.sub_agents else [] async def _on_invoke(ctx, args_json: str) -> str: """Invoked by the Agents SDK when the LLM calls the delegate tool.""" args = json.loads(args_json) return await _delegate_impl( - agent_name=args["agent_name"], message=args["message"], - allowed_sub_agents=allowed, + agent_name=args.get("agent_name"), + agent_url=args.get("agent_url"), + allowed_sub_agents=allowed or None, from_agent_name=agent_def.name, agent_registry=agent_registry, local_base_url=local_base_url, @@ -494,28 +478,51 @@ async def _on_invoke(ctx, args_json: str) -> str: context_headers=context_headers, ) + # Build a description that reflects available modes + if allowed: + desc = ( + "Delegate a task to another agent. " + f"Known sub-agents (by name): {allowed}. " + "You can also delegate to any agent by URL (e.g. from discover_agents). " + "Provide either agent_name or agent_url, plus a message." + ) + else: + desc = ( + "Send a task to another agent. " + "Use discover_agents to find available agents, then provide " + "agent_name (for local/configured agents) or agent_url (for " + "any agent). Returns the agent's response as JSON." + ) + return FunctionTool( name="delegate", - description=( - "Delegate a task to a sub-agent by name. " - f"Available sub-agents: {allowed}. " - "Returns the agent's response as JSON." - ), + description=desc, params_json_schema={ "type": "object", "properties": { "agent_name": { "type": "string", - "description": f"Name of the sub-agent to delegate to. Must be one of: {allowed}", + "description": ( + "Name of the agent to delegate to. " + + (f"Known sub-agents: {allowed}. " if allowed else "") + + "Use this for local or configured agents." + ), + }, + "agent_url": { + "type": "string", + "description": ( + "Full A2A URL of the agent (e.g. http://host:port/agents/name). " + "Use this for agents discovered at runtime." + ), }, "message": { "type": "string", - "description": "The task or message to send to the sub-agent.", + "description": "The task or message to send to the agent.", }, }, - "required": ["agent_name", "message"], + "required": ["message"], "additionalProperties": False, }, on_invoke_tool=_on_invoke, - strict_json_schema=True, + strict_json_schema=False, ) diff --git a/src/opensensa/framework_tools/discover_agents.py b/src/opensensa/framework_tools/discover_agents.py index bf51ead..ab5a7ee 100644 --- a/src/opensensa/framework_tools/discover_agents.py +++ b/src/opensensa/framework_tools/discover_agents.py @@ -45,7 +45,7 @@ def register( description=( "Discover available AI agents — both local and remote. Returns agent names, " "descriptions, skills, and capabilities. Use this to find agents that can " - "handle specific tasks before delegating with send_to_agent." + "handle specific tasks before delegating with the delegate tool." ), tags=["framework", "a2a", "discovery"], ) diff --git a/src/opensensa/framework_tools/send_to_agent.py b/src/opensensa/framework_tools/send_to_agent.py deleted file mode 100644 index 0232812..0000000 --- a/src/opensensa/framework_tools/send_to_agent.py +++ /dev/null @@ -1,148 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""Framework tool: send_to_agent — sends A2A SendMessage to a remote agent.""" - -import json -import logging -from typing import Any, Optional - -import httpx - -logger = logging.getLogger("opensensa.framework_tools") - -# Maximum delegation depth to prevent infinite loops -MAX_A2A_DEPTH = 5 - -# Header used to propagate current delegation depth -_DEPTH_HEADER = "X-A2A-Depth" - - -def register(mcp): - """Register the send_to_agent tool.""" - - @mcp.tool( - title="Send Message to Agent", - description=( - "Send a message to another AI agent via the A2A protocol. Use discover_agents " - "first to find the right agent and its URL, then use this tool to delegate a task. " - "Returns the agent's response as an A2A Task with artifacts." - ), - tags=["framework", "a2a", "delegation"], - ) - async def send_to_agent( - agent_url: str, - message: str, - task_id: Optional[str] = None, - blocking: bool = True, - current_depth: int = 0, - client_request_id: Optional[str] = None, - context_headers: Optional[dict] = None, - ) -> dict[str, Any]: - """Send an A2A SendMessage JSON-RPC request to a remote agent. - - Args: - agent_url: Base URL of the target agent (e.g. http://localhost:9000). - message: The text message to send to the agent. - task_id: Optional existing task ID for multi-turn conversations. - blocking: If True, wait for the task to complete. If False, return immediately. - current_depth: Current delegation depth (auto-managed by framework). - client_request_id: Original client JSON-RPC id for session tracking. - context_headers: Context headers to cascade to the target agent. - - Returns: - A2A Task response with status and artifacts. - """ - # Enforce depth limit to prevent infinite delegation loops - if current_depth >= MAX_A2A_DEPTH: - logger.warning( - f"A2A delegation depth limit reached ({current_depth}/{MAX_A2A_DEPTH}). " - f"Refusing to delegate to {agent_url}." - ) - return { - "status": "error", - "error": ( - f"Delegation depth limit reached ({MAX_A2A_DEPTH}). " - "This prevents infinite agent-to-agent loops. " - "Try handling this task directly instead of delegating." - ), - } - - url = agent_url.rstrip("/") - # POST to the agent's root — this is the JSON-RPC endpoint - # for per-agent sub-apps mounted at /agents/{name}/ - a2a_endpoint = f"{url}/" - - # Build JSON-RPC SendMessage request - import uuid - - params: dict[str, Any] = { - "message": { - "role": "user", - "parts": [{"kind": "text", "text": message}], - "messageId": str(uuid.uuid4()), - } - } - if task_id: - params["message"]["taskId"] = task_id - - # Cascade context headers to sub-agent via A2A metadata - if context_headers: - params.setdefault("metadata", {})["context_headers"] = context_headers - - payload = { - "jsonrpc": "2.0", - "id": client_request_id or str(uuid.uuid4()), - "method": "message/send", - "params": params, - } - - # Propagate depth via header so the receiving agent can track it - next_depth = current_depth + 1 - headers = { - "Content-Type": "application/json", - _DEPTH_HEADER: str(next_depth), - } - # Propagate the original client request id - if client_request_id: - headers["X-A2A-Client-Request-Id"] = client_request_id - - try: - async with httpx.AsyncClient(timeout=120.0) as client: - resp = await client.post( - a2a_endpoint, - json=payload, - headers=headers, - ) - resp.raise_for_status() - result = resp.json() - - if "error" in result: - return { - "status": "error", - "error": result["error"], - } - - return { - "status": "success", - "result": result.get("result", {}), - } - - except httpx.TimeoutException: - return {"status": "error", "error": f"Request to {a2a_endpoint} timed out"} - except Exception as e: - logger.error(f"A2A SendMessage to {a2a_endpoint} failed: {e}") - return {"status": "error", "error": str(e)} diff --git a/src/opensensa/interactive/chat.py b/src/opensensa/interactive/chat.py index 7a67988..1a1a998 100644 --- a/src/opensensa/interactive/chat.py +++ b/src/opensensa/interactive/chat.py @@ -261,21 +261,14 @@ def _run_mcp_background(config: AppConfig) -> None: registry = AgentRegistry(agents_dir) remote_agents = [{"url": ra.url} for ra in config.remote_agents] - # Build local base URL for discover_agents - advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host - local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" - - from opensensa.framework_tools import create_agent, edit_agent, delete_agent, discover_agents, list_tools, send_to_agent + from opensensa.framework_tools import create_agent, edit_agent, delete_agent, discover_agents, list_tools discover_agents.register( mcp, agent_registry=registry, remote_agents=remote_agents, - local_base_url=local_base_url, + local_base_url=config.server.local_base_url, ) - send_to_agent.register(mcp) - # NOTE: delegate is NOT registered on MCP — it is a native FunctionTool - # wired directly into the Agent by agent_builder.py (A2A, not MCP). create_agent.register(mcp, agents_directory=config.agents.directory) edit_agent.register(mcp, agents_directory=config.agents.directory) delete_agent.register(mcp, agents_directory=config.agents.directory) diff --git a/src/opensensa/orchestrator/agent_builder.py b/src/opensensa/orchestrator/agent_builder.py index db26f5b..132f2ce 100644 --- a/src/opensensa/orchestrator/agent_builder.py +++ b/src/opensensa/orchestrator/agent_builder.py @@ -118,25 +118,24 @@ def _tool_filter(tool) -> bool: # Build native function tools (non-MCP) native_tools: list[Any] = [] - # If agent declares sub_agents, add the delegate tool as a native FunctionTool. - # This calls sub-agents over A2A directly — no MCP timeout issues. - if agent_def.sub_agents: - from opensensa.framework_tools.delegate import build_delegate_tool - - advertise_host = "localhost" if config.server.host == "0.0.0.0" else config.server.host - local_base_url = f"http://{advertise_host}:{config.server.orchestrator_port}" - - delegate_tool = build_delegate_tool( - agent_def, - agent_registry=agent_registry, - local_base_url=local_base_url, - remote_agents=remote_agents, - call_graph=call_graph, - client_request_id=client_request_id, - context_headers=context_headers, - current_depth=current_depth, - ) - native_tools.append(delegate_tool) + # Always attach the delegate tool as a native FunctionTool. + # When the agent declares sub_agents, delegation is restricted to that + # allowlist. Otherwise, the agent can delegate to any agent by name + # (via registry) or URL (e.g. discovered via discover_agents). + # Delegation calls sub-agents over A2A directly — no MCP timeout issues. + from opensensa.framework_tools.delegate import build_delegate_tool + + delegate_tool = build_delegate_tool( + agent_def, + agent_registry=agent_registry, + local_base_url=config.server.local_base_url, + remote_agents=remote_agents, + call_graph=call_graph, + client_request_id=client_request_id, + context_headers=context_headers, + current_depth=current_depth, + ) + native_tools.append(delegate_tool) # Build agent agent = Agent( diff --git a/src/opensensa/orchestrator/server.py b/src/opensensa/orchestrator/server.py index 283cba2..7af41eb 100644 --- a/src/opensensa/orchestrator/server.py +++ b/src/opensensa/orchestrator/server.py @@ -114,12 +114,7 @@ def create_orchestrator_app( """ app = FastAPI(title="Agent Server", version="0.1.0") - host = config.server.host - port = config.server.orchestrator_port - # Use localhost for the Agent Card URLs so local clients can reach them. - # For 0.0.0.0 we advertise localhost; for explicit IPs we use that. - advertise_host = "localhost" if host == "0.0.0.0" else host - base_url = f"http://{advertise_host}:{port}" + base_url = config.server.local_base_url if mcp_server_url is None: mcp_server_url = f"http://localhost:{config.server.mcp_port}/mcp" @@ -177,7 +172,7 @@ def create_orchestrator_app( mcp_server_url=mcp_server_url, ) app.include_router(web_router) - logger.info(f"Web UI enabled at http://{advertise_host}:{port}/web") + logger.info(f"Web UI enabled at {config.server.local_base_url}/web") except Exception: logger.warning("Failed to enable web UI", exc_info=True) diff --git a/src/opensensa/web/call_graph.py b/src/opensensa/web/call_graph.py deleted file mode 100644 index cb98625..0000000 --- a/src/opensensa/web/call_graph.py +++ /dev/null @@ -1,404 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""SSE-emitting call graph adapter for the web frontend. - -Wraps the terminal ``CallGraph`` and pushes JSON-serializable events to an -``asyncio.Queue`` that the SSE endpoint drains. -""" - -from __future__ import annotations - -import asyncio -import json -import time -from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, Optional - - -# --------------------------------------------------------------------------- -# Lightweight node (mirrors interactive/call_graph._CallNode) -# --------------------------------------------------------------------------- - -@dataclass -class CallNode: - """A single node in the web call graph.""" - - id: str - label: str - kind: str # "tool", "delegation", "llm", "agent" - status: str = "running" # running | completed | failed - start_time: float = field(default_factory=time.monotonic) - end_time: Optional[float] = None - duration_ms: Optional[int] = None - result_preview: Optional[str] = None - children: list["CallNode"] = field(default_factory=list) - - def finish( - self, - *, - duration_ms: int | None = None, - result_preview: str | None = None, - status: str = "completed", - ) -> None: - self.end_time = time.monotonic() - self.duration_ms = duration_ms or int((self.end_time - self.start_time) * 1000) - self.result_preview = result_preview - self.status = status - - def to_dict(self) -> dict[str, Any]: - """Serialize this node (and its children) to a plain dict.""" - return { - "id": self.id, - "label": self.label, - "kind": self.kind, - "status": self.status, - "duration_ms": self.duration_ms, - "result_preview": self.result_preview, - "children": [c.to_dict() for c in self.children], - } - - -# --------------------------------------------------------------------------- -# WebCallGraph — pushes SSE events -# --------------------------------------------------------------------------- - -class WebCallGraph: - """Call graph that emits SSE events instead of rendering Rich trees. - - Each event method pushes a JSON message to an asyncio Queue. - The SSE endpoint reads from this queue and streams to the browser. - """ - - def __init__(self, agent_name: str) -> None: - self.agent_name = agent_name - self._root_nodes: list[CallNode] = [] - self._active_stack: list[CallNode] = [] - self._node_map: dict[str, CallNode] = {} - self._key_seq: int = 0 - self._turn_start: float = time.monotonic() - self._total_tokens: int = 0 - self._llm_calls: int = 0 - self._tool_calls: int = 0 - self._delegations: int = 0 - self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() - self._finished = False - - # -- Key generation ------------------------------------------------------ - - def _next_key(self, prefix: str) -> str: - self._key_seq += 1 - return f"{prefix}:{self._key_seq}" - - def _find_running(self, prefix: str) -> CallNode | None: - for key in reversed(list(self._node_map)): - if key.startswith(prefix + ":"): - node = self._node_map[key] - if node.status == "running": - return node - return None - - # -- SSE event helpers --------------------------------------------------- - - def _emit(self, event_type: str, data: dict[str, Any] | None = None) -> None: - """Push an SSE event onto the queue.""" - payload: dict[str, Any] = {"event": event_type} - if data: - payload.update(data) - # Include the full tree snapshot so the client can re-render - payload["tree"] = [n.to_dict() for n in self._root_nodes] - payload["stats"] = { - "tool_calls": self._tool_calls, - "delegations": self._delegations, - "llm_calls": self._llm_calls, - "total_tokens": self._total_tokens, - "elapsed_ms": int((time.monotonic() - self._turn_start) * 1000), - } - try: - self._queue.put_nowait(payload) - except asyncio.QueueFull: - pass # drop event if queue is full (shouldn't happen) - - def _add_node(self, node: CallNode, key: str) -> None: - self._node_map[key] = node - if self._active_stack: - self._active_stack[-1].children.append(node) - else: - self._root_nodes.append(node) - - # -- Event methods (same interface as interactive CallGraph) ------------- - - def start_llm(self, agent_name: str | None = None) -> None: - label = f"LLM → {agent_name}" if agent_name else "LLM call" - key = self._next_key("llm") - node = CallNode(id=key, label=label, kind="llm") - self._add_node(node, key) - self._llm_calls += 1 - self._emit("llm_start", {"node_id": key, "label": label}) - - def end_llm( - self, - agent_name: str | None = None, - duration_ms: int = 0, - tokens: int = 0, - ) -> None: - node = self._find_running("llm") - if node: - result = f"{tokens} tokens" if tokens else None - node.finish(duration_ms=duration_ms, result_preview=result) - self._total_tokens += tokens - if self._active_stack and self._active_stack[-1] is node: - self._active_stack.pop() - self._emit("llm_end", { - "node_id": node.id, - "duration_ms": duration_ms, - "tokens": tokens, - }) - - def start_tool(self, tool_name: str) -> None: - key = self._next_key("tool") - if tool_name == "delegate": - node = CallNode(id=key, label="delegating…", kind="delegation") - self._add_node(node, key) - self._delegations += 1 - else: - node = CallNode(id=key, label=tool_name, kind="tool") - self._add_node(node, key) - self._tool_calls += 1 - self._emit("tool_start", {"node_id": key, "tool": tool_name}) - - def end_tool( - self, - tool_name: str, - duration_ms: int = 0, - result: str | None = None, - ) -> None: - if tool_name == "delegate": - node = self._find_running("tool") - if node and node.kind != "delegation": - node = None - else: - node = self._find_running("tool") - if node and node.label != tool_name: - node = None - if not node: - return - - if tool_name == "delegate" and result: - self._finish_delegate_node(node, result, duration_ms) - else: - preview = result[:80].replace("\n", " ") if result else None - node.finish(duration_ms=duration_ms, result_preview=preview) - - if self._active_stack and self._active_stack[-1] is node: - self._active_stack.pop() - - self._emit("tool_end", { - "node_id": node.id, - "tool": tool_name, - "duration_ms": duration_ms, - "result_preview": node.result_preview, - }) - - def start_delegation(self, from_agent: str, to_agent: str, *, message: str = "") -> None: - key = self._next_key("delegation") - label = f"{from_agent} → {to_agent}" - node = CallNode(id=key, label=label, kind="delegation") - self._add_node(node, key) - self._active_stack.append(node) - self._delegations += 1 - self._emit("delegation_start", { - "node_id": key, - "from_agent": from_agent, - "to_agent": to_agent, - "message": message, - }) - - def end_delegation(self, to_agent: str, duration_ms: int = 0, *, response: str = "") -> None: - node = self._find_running("delegation") - if node: - node.finish(duration_ms=duration_ms) - if self._active_stack and self._active_stack[-1] is node: - self._active_stack.pop() - self._emit("delegation_end", { - "node_id": node.id, - "to_agent": to_agent, - "duration_ms": duration_ms, - "response": response, - }) - - def start_agent(self, agent_name: str) -> None: - if agent_name != self.agent_name: - key = self._next_key("agent") - node = CallNode(id=key, label=agent_name, kind="agent") - self._add_node(node, key) - self._emit("agent_start", {"node_id": key, "agent": agent_name}) - - def end_agent(self, agent_name: str, duration_ms: int = 0) -> None: - node = self._find_running("agent") - if node: - node.finish(duration_ms=duration_ms) - if self._active_stack and self._active_stack[-1] is node: - self._active_stack.pop() - self._emit("agent_end", { - "node_id": node.id, - "agent": agent_name, - "duration_ms": duration_ms, - }) - - def update_delegation_target(self, agent_name: str) -> None: - node = self._find_running("tool") - if node and node.kind == "delegation": - node.label = f"delegate → {agent_name}" - self._emit("delegation_update", {"node_id": node.id, "agent": agent_name}) - - def add_delegation_sub_event( - self, - event_type: str, - tool_name: str, - *, - agent_name: str | None = None, - tools_used: list[dict] | None = None, - ) -> None: - node = self._find_running("tool") - if not node or node.kind != "delegation": - return - - if event_type == "tool_start": - key = self._next_key("sub_tool") - if tool_name == "delegate" and agent_name: - label = f"delegate → {agent_name}" - elif tool_name == "delegate": - label = "delegating…" - else: - label = tool_name - child = CallNode( - id=key, - label=label, - kind=("delegation" if tool_name == "delegate" else "tool"), - ) - node.children.append(child) - elif event_type == "tool_end": - for child in reversed(node.children): - if child.status != "running": - continue - if tool_name == "delegate" and child.kind == "delegation": - if agent_name: - child.label = f"delegate → {agent_name}" - child.finish() - break - elif child.label == tool_name and child.kind == "tool": - child.finish() - break - - self._emit("delegation_sub_event", { - "parent_id": node.id, - "sub_event": event_type, - "tool": tool_name, - }) - - # -- Delegate result parsing --------------------------------------------- - - def _finish_delegate_node( - self, node: CallNode, result_json: str, duration_ms: int - ) -> None: - try: - data = json.loads(result_json) - except (ValueError, TypeError): - node.finish(duration_ms=duration_ms, result_preview=result_json[:80]) - return - - agent_name = data.get("agent", "") - status = data.get("status", "unknown") - - if agent_name: - node.label = f"delegate → {agent_name}" - node.kind = "delegation" - - tools_used = data.get("tools_used", []) - if tools_used: - node.children.clear() - self._add_tool_children(node, tools_used) - - if status == "success": - node.finish(duration_ms=duration_ms, result_preview="completed") - elif status == "error": - error_msg = data.get("error", "unknown error")[:60] - node.finish(duration_ms=duration_ms, result_preview=error_msg, status="failed") - else: - node.finish(duration_ms=duration_ms) - - def _add_tool_children(self, parent: CallNode, tools_used: list[dict]) -> None: - for tool_info in tools_used: - tname = tool_info.get("name", "unknown") - agent = tool_info.get("agent") - nested = tool_info.get("tools_used", []) - key = self._next_key("sub_tool") - - if tname == "delegate" and agent: - child = CallNode( - id=key, - label=f"delegate → {agent}", - kind="delegation", - status="completed", - ) - child.end_time = child.start_time - self._add_tool_children(child, nested) - else: - child = CallNode( - id=key, label=tname, kind="tool", status="completed", - ) - child.end_time = child.start_time - - parent.children.append(child) - - # -- Stats --------------------------------------------------------------- - - @property - def elapsed_ms(self) -> int: - return int((time.monotonic() - self._turn_start) * 1000) - - @property - def is_empty(self) -> bool: - return len(self._root_nodes) == 0 - - # -- SSE stream ---------------------------------------------------------- - - def finish(self, response: str | None = None) -> None: - """Signal that the agent turn is complete.""" - self._emit("turn_complete", { - "response": response, - }) - self._finished = True - - def error(self, message: str) -> None: - """Signal that the agent turn failed.""" - self._emit("turn_error", {"error": message}) - self._finished = True - - async def events(self) -> AsyncGenerator[str, None]: - """Yield SSE-formatted strings. Blocks until events arrive or turn finishes.""" - while True: - try: - payload = await asyncio.wait_for(self._queue.get(), timeout=0.5) - yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - if payload.get("event") in ("turn_complete", "turn_error"): - return - except asyncio.TimeoutError: - # Send keepalive to prevent connection timeout - yield ": keepalive\n\n" - if self._finished: - return diff --git a/src/opensensa/web/chat_manager.py b/src/opensensa/web/chat_manager.py deleted file mode 100644 index c250825..0000000 --- a/src/opensensa/web/chat_manager.py +++ /dev/null @@ -1,321 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""Web chat session manager — manages concurrent browser chat sessions. - -Each session holds conversation history, an agent definition, and references -to the shared MCP / A2A servers (no per-session subprocess spawning). -""" - -from __future__ import annotations - -import asyncio -import logging -import time -import uuid -from contextlib import AsyncExitStack -from typing import Any, AsyncGenerator - -from opensensa.config import AppConfig -from opensensa.orchestrator.agent_registry import AgentDefinition, AgentRegistry -from opensensa.web.call_graph import WebCallGraph - -logger = logging.getLogger("opensensa.web") - - -class ChatSession: - """One browser chat session with a single agent.""" - - def __init__( - self, - session_id: str, - agent_def: AgentDefinition, - config: AppConfig, - registry: AgentRegistry, - mcp_server_url: str, - ) -> None: - self.session_id = session_id - self.agent_def = agent_def - self.config = config - self.registry = registry - self.mcp_server_url = mcp_server_url - self.conversation_history: list[Any] = [] - self.created_at: float = time.time() - - def to_dict(self) -> dict[str, Any]: - return { - "session_id": self.session_id, - "agent_name": self.agent_def.name, - "history_length": len(self.conversation_history), - "created_at": self.created_at, - } - - -def _make_web_hooks(call_graph: WebCallGraph): - """Create RunHooks that push events into a WebCallGraph. - - Returns None if the agents SDK doesn't expose RunHooks. - """ - try: - from agents import RunHooks # type: ignore[attr-defined] - - class _WebHooks(RunHooks): - def __init__(self, graph: WebCallGraph): - self._graph = graph - self._llm_start_time: float = 0.0 - self._tool_start_times: dict[str, float] = {} - self._agent_start_time: float = 0.0 - - async def on_llm_start(self, context, agent, system_prompt, input_items): - self._llm_start_time = time.monotonic() - agent_name = getattr(agent, "name", str(agent)) - self._graph.start_llm(agent_name) - - async def on_llm_end(self, context, agent, response): - duration_ms = int((time.monotonic() - self._llm_start_time) * 1000) - usage = getattr(response, "usage", None) - total_tokens = 0 - if usage: - total_tokens = getattr(usage, "total_tokens", 0) - agent_name = getattr(agent, "name", str(agent)) - self._graph.end_llm( - agent_name=agent_name, - duration_ms=duration_ms, - tokens=total_tokens, - ) - - async def on_tool_start(self, context, agent, tool): - name = getattr(tool, "name", None) or str(tool) - self._tool_start_times[name] = time.monotonic() - self._graph.start_tool(name) - - async def on_tool_end(self, context, agent, tool, result): - name = getattr(tool, "name", None) or str(tool) - duration_ms = int( - (time.monotonic() - self._tool_start_times.pop(name, time.monotonic())) * 1000 - ) - text = str(result) if result else "" - self._graph.end_tool(name, duration_ms=duration_ms, result=text) - - async def on_agent_start(self, context, agent): - self._agent_start_time = time.monotonic() - agent_name = getattr(agent, "name", str(agent)) - self._graph.start_agent(agent_name) - - async def on_agent_end(self, context, agent, output): - duration_ms = int((time.monotonic() - self._agent_start_time) * 1000) - agent_name = getattr(agent, "name", str(agent)) - self._graph.end_agent(agent_name, duration_ms=duration_ms) - - async def on_handoff(self, context, from_agent, to_agent): - from_name = getattr(from_agent, "name", str(from_agent)) - to_name = getattr(to_agent, "name", str(to_agent)) - self._graph.start_delegation(from_name, to_name) - - return _WebHooks(graph=call_graph) - except ImportError: - return None - except Exception: - logger.warning("Failed to create web RunHooks", exc_info=True) - return None - - -class ChatManager: - """Manages multiple concurrent browser chat sessions.""" - - def __init__( - self, - config: AppConfig, - registry: AgentRegistry, - mcp_server_url: str, - ) -> None: - self.config = config - self.registry = registry - self.mcp_server_url = mcp_server_url - self._sessions: dict[str, ChatSession] = {} - - def create_session(self, agent_name: str) -> ChatSession: - """Create a new chat session for the given agent.""" - agent_def = self.registry.get(agent_name) - if not agent_def: - raise ValueError(f"Agent '{agent_name}' not found") - - session_id = str(uuid.uuid4())[:8] - session = ChatSession( - session_id=session_id, - agent_def=agent_def, - config=self.config, - registry=self.registry, - mcp_server_url=self.mcp_server_url, - ) - self._sessions[session_id] = session - return session - - def get_session(self, session_id: str) -> ChatSession | None: - return self._sessions.get(session_id) - - def delete_session(self, session_id: str) -> bool: - return self._sessions.pop(session_id, None) is not None - - def list_sessions(self) -> list[dict]: - return [s.to_dict() for s in self._sessions.values()] - - async def send_message( - self, - session_id: str, - user_input: str, - *, - context_headers: dict[str, str] | None = None, - ) -> AsyncGenerator[str, None]: - """Run one agent turn and yield SSE events. - - This is an async generator — the SSE endpoint iterates it. - - Args: - context_headers: Optional per-request headers extracted from the - client HTTP request (e.g. X-Document-URL, X-Project). These - override the static defaults from the agent definition and are - forwarded to MCP tool servers. - """ - session = self._sessions.get(session_id) - if not session: - yield f'data: {{"event": "turn_error", "error": "Session not found"}}\n\n' - return - - # Create WebCallGraph + hooks for this turn - call_graph = WebCallGraph(agent_name=session.agent_def.name) - hooks = _make_web_hooks(call_graph) - - # Start the agent turn in a background task - agent_task = asyncio.create_task( - self._run_agent_turn(session, user_input, call_graph, hooks, context_headers=context_headers) - ) - - # Stream events from the call graph - async for event_str in call_graph.events(): - yield event_str - - # Ensure the task is done (it should be — call_graph.finish() was called) - try: - await agent_task - except Exception: - pass # errors already emitted via call_graph.error() - - async def _run_agent_turn( - self, - session: ChatSession, - user_input: str, - call_graph: WebCallGraph, - hooks: Any, - *, - context_headers: dict[str, str] | None = None, - ) -> None: - """Execute one agent turn. Pushes events to call_graph, finishes with response or error.""" - try: - from agents import Runner - from opensensa.orchestrator.agent_builder import build_agent - - async with AsyncExitStack() as exit_stack: - # Infrastructure headers required by the MCP tool server - # (X-Conversation-Id is mandatory for tool logging). - mcp_headers: dict[str, str] = { - "X-Conversation-Id": session.session_id, - "X-Agent-Id": session.agent_def.name, - "X-Agent-Depth": "0", - } - # Per-request context headers from the client (e.g. X-Document-URL) - if context_headers: - mcp_headers.update(context_headers) - logger.info( - "_run_agent_turn: agent=%s context_headers=%s mcp_headers=%s", - session.agent_def.name, - context_headers, - mcp_headers, - ) - - remote_agents = [ - {"url": ra.url, "name": getattr(ra, "name", "")} - for ra in session.config.remote_agents - ] - - agent = await build_agent( - agent_def=session.agent_def, - config=session.config, - mcp_server_url=session.mcp_server_url, - exit_stack=exit_stack, - mcp_headers=mcp_headers or None, - agent_registry=session.registry, - remote_agents=remote_agents or None, - call_graph=call_graph, - ) - - # Build input - if session.conversation_history: - input_items = session.conversation_history.copy() - input_items.append({"role": "user", "content": user_input}) - run_input: Any = input_items - else: - run_input = user_input - - result = await asyncio.wait_for( - Runner.run( - starting_agent=agent, - input=run_input, - hooks=hooks, - max_turns=25, - ), - timeout=300.0, - ) - - # Persist history - session.conversation_history = result.to_input_list() - - # Extract response text - response = "" - if result.final_output: - response = str(result.final_output) - elif result.new_items: - parts = [] - for item in result.new_items: - for attr in ("text", "output"): - if hasattr(item, attr): - parts.append(str(getattr(item, attr))) - break - response = "\n".join(parts) if parts else "Agent completed with no output." - else: - response = "Agent completed with no output." - - call_graph.finish(response=response) - - except asyncio.TimeoutError: - call_graph.error("Request timed out after 5 minutes.") - except Exception as exc: - logger.error("Agent turn failed", exc_info=True) - msg = str(exc).strip() - lower = msg.lower() - - if any(kw in lower for kw in ("401", "unauthorized", "api key", "authentication")): - call_graph.error("Authentication failed — check your API key.") - elif any(kw in lower for kw in ("429", "rate limit", "rate_limit", "quota")): - call_graph.error("Rate-limited — wait a moment and try again.") - elif any(kw in lower for kw in ("model_not_found", "model not found", "does not exist")): - call_graph.error("Model not found — check the model name in your agent config.") - elif any(kw in lower for kw in ("failed to connect to mcp", "session terminated", "mcp")): - call_graph.error("MCP connection failed — check the MCP server URL and ensure it is running.") - elif any(kw in lower for kw in ("connection", "timed out", "timeout", "unreachable")): - call_graph.error("Connection error — unable to reach the LLM provider.") - else: - call_graph.error(msg[:300] if len(msg) > 300 else msg) diff --git a/src/opensensa/web/routes.py b/src/opensensa/web/routes.py index d91047d..b80b851 100644 --- a/src/opensensa/web/routes.py +++ b/src/opensensa/web/routes.py @@ -19,7 +19,9 @@ Provides: - Static file serving + index.html - Agent CRUD REST endpoints - - Chat session management + SSE streaming + +Chat messaging goes directly to the A2A endpoints at /agents/{name}/ +using JSON-RPC message/stream — no server-side session management. """ from __future__ import annotations @@ -31,12 +33,11 @@ import yaml from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse from pydantic import BaseModel from opensensa.config import AppConfig from opensensa.orchestrator.agent_registry import AgentRegistry -from opensensa.web.chat_manager import ChatManager logger = logging.getLogger("opensensa.web") @@ -49,14 +50,6 @@ # Pydantic request models # --------------------------------------------------------------------------- -class CreateSessionRequest(BaseModel): - agent_name: str - - -class SendMessageRequest(BaseModel): - message: str - - class CreateAgentRequest(BaseModel): name: str description: str @@ -86,11 +79,6 @@ def create_web_router( """Build and return the web frontend APIRouter.""" router = APIRouter() - chat_manager = ChatManager( - config=config, - registry=registry, - mcp_server_url=mcp_server_url, - ) agents_dir = Path(config.agents.directory).resolve() # -- Static files + index ----------------------------------------------- @@ -260,73 +248,4 @@ async def delete_agent(name: str): registry.scan() return {"status": "deleted", "name": name} - # -- Chat sessions ------------------------------------------------------- - - @router.post("/api/chat/sessions") - async def create_session(req: CreateSessionRequest): - """Create a new chat session.""" - try: - session = chat_manager.create_session(req.agent_name) - return session.to_dict() - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - @router.get("/api/chat/sessions") - async def list_sessions(): - """List active chat sessions.""" - return chat_manager.list_sessions() - - @router.delete("/api/chat/sessions/{session_id}") - async def delete_session(session_id: str): - """Delete a chat session.""" - if not chat_manager.delete_session(session_id): - raise HTTPException(status_code=404, detail="Session not found") - return {"status": "deleted"} - - @router.post("/api/chat/sessions/{session_id}/reset") - async def reset_session(session_id: str): - """Reset conversation history for a session.""" - session = chat_manager.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - session.conversation_history = [] - return {"status": "reset", "session_id": session_id} - - @router.post("/api/chat/sessions/{session_id}/messages") - async def send_message(session_id: str, req: SendMessageRequest, request: Request): - """Send a message to the agent. Returns an SSE stream.""" - session = chat_manager.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - - # Extract context headers declared by the agent from the client request. - # e.g. healthbuddy declares X-Document-URL and X-Project — if the - # client sends those headers they get forwarded to MCP tool servers. - context_headers: dict[str, str] = {} - declared = session.agent_def.context_headers - logger.info( - "send_message: session=%s agent=%s declared_context_headers=%s", - session_id, session.agent_def.name, declared, - ) - if declared: - for header_name in declared: - value = request.headers.get(header_name) - logger.info( - " header %s → %s", - header_name, repr(value), - ) - if value: - context_headers[header_name] = value - logger.info("send_message: resolved context_headers=%s", context_headers) - - return StreamingResponse( - chat_manager.send_message(session_id, req.message, context_headers=context_headers or None), - media_type="text/event-stream; charset=utf-8", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - return router diff --git a/src/opensensa/web/static/app.js b/src/opensensa/web/static/app.js index 8072536..cc60438 100644 --- a/src/opensensa/web/static/app.js +++ b/src/opensensa/web/static/app.js @@ -26,11 +26,11 @@ * module's JSDoc header for standalone usage instructions. */ -import { Sidebar } from "./modules/sidebar.js"; -import { ChatPanel } from "./modules/chat-panel.js"; +import { Sidebar } from "./modules/sidebar.js"; +import { ChatPanel } from "./modules/chat-panel.js"; import { DelegationTree } from "./modules/delegation-tree.js"; -import { AgentModal } from "./modules/agent-modal.js"; -import { createApi } from "./modules/api.js"; +import { AgentModal } from "./modules/agent-modal.js"; +import { createApi } from "./modules/api.js"; /** * @typedef {Object} OpenSensaOptions @@ -93,29 +93,29 @@ const LAYOUT = ` * @returns {{ destroy: () => void }} */ export default function mount(rootEl, options = {}) { - const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); + const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); const storagePrefix = options.storagePrefix || "opensensa"; - const marked = options.marked || /** @type {any} */ (window)["marked"]; - const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; + const marked = options.marked || /** @type {any} */ (window)["marked"]; + const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; // ── Inject layout skeleton ─────────────────────────────── rootEl.classList.add("root"); rootEl.innerHTML = LAYOUT; const ref = (/** @type {string} */ n) => - /** @type {HTMLElement} */ (rootEl.querySelector(`[data-ref="${n}"]`)); + /** @type {HTMLElement} */(rootEl.querySelector(`[data-ref="${n}"]`)); // ── Instantiate widgets ────────────────────────────────── - const api = createApi(baseUrl); - const sidebar = new Sidebar(ref("sidebar-container")); - const chat = new ChatPanel(ref("chat-container"), { baseUrl, storagePrefix, marked, hljs }); - const tree = new DelegationTree(ref("tree-container"), { marked }); - const modal = new AgentModal(rootEl); + const api = createApi(baseUrl); + const sidebar = new Sidebar(ref("sidebar-container")); + const chat = new ChatPanel(ref("chat-container"), { baseUrl, storagePrefix, marked, hljs }); + const tree = new DelegationTree(ref("tree-container"), { marked }); + const modal = new AgentModal(rootEl); const treeLinkArrow = ref("tree-link-arrow"); - // Per-agent state (session IDs, message history) stored in orchestrator - /** @type {Map, contextHeaders: string[] }>} */ + // Per-agent state (message history) stored in orchestrator + /** @type {Map, contextHeaders: string[] }>} */ const agentState = new Map(); // ── Helpers ────────────────────────────────────────────── @@ -134,7 +134,7 @@ export default function mount(rootEl, options = {}) { // Store context_headers per agent for (const a of agentList) { if (!agentState.has(a.name)) { - agentState.set(a.name, { sessionId: null, messages: [], contextHeaders: a.context_headers || [] }); + agentState.set(a.name, { messages: [], contextHeaders: a.context_headers || [] }); } } } catch (err) { @@ -177,29 +177,6 @@ export default function mount(rootEl, options = {}) { } catch { modal.openEdit({ name }); } }); - /** Recursive search for a node in the SSE tree structure. */ - function findInTree(/** @type {any[]} */ nodes, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { - for (const n of nodes) { - if (n.id === nodeId) return { node: n, ancestors }; - if (n.children?.length) { - const r = findInTree(n.children, nodeId, [...ancestors, n]); - if (r) return r; - } - } - return null; - } - - /** Find the delegation ancestor for an SSE event node. */ - function getDelegationIdForEvent(/** @type {any[]} */ sseTree, /** @type {string} */ nodeId) { - if (!nodeId || !sseTree) return null; - const r = findInTree(sseTree, nodeId); - if (!r) return null; - for (const anc of r.ancestors) { - if (anc.kind === "delegation" && tree.get(anc.id)) return anc.id; - } - return null; - } - // SSE event routing — delegation and tool events go to the tree widget chat.on("sseEvent", (/** @type {any} */ evt) => { switch (evt.event) { @@ -214,19 +191,13 @@ export default function mount(rootEl, options = {}) { } break; - case "tool_start": { - const delId = getDelegationIdForEvent(evt.tree, evt.node_id); - if (delId) tree.startTool({ delegationId: delId, nodeId: evt.node_id, toolName: evt.tool || "tool" }); - else chat.showToolChip(evt.node_id, evt.tool || "tool", "running"); + case "tool_start": + // Tool chips for direct (non-delegation) tools are handled + // inside chat-panel.js _handleA2AEvent already break; - } - case "tool_end": { - const delId = getDelegationIdForEvent(evt.tree, evt.node_id); - if (delId) tree.endTool({ delegationId: delId, nodeId: evt.node_id, toolName: evt.tool || "tool", durationMs: evt.duration_ms }); - else chat.showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms); + case "tool_end": break; - } case "delegation_start": tree.startDelegation({ @@ -243,18 +214,6 @@ export default function mount(rootEl, options = {}) { updateTreeArrow(); break; - case "llm_start": { - const delId = getDelegationIdForEvent(evt.tree, evt.node_id); - if (!delId) chat.showThinking(); - break; - } - - case "llm_end": { - const delId = getDelegationIdForEvent(evt.tree, evt.node_id); - if (!delId) chat.hideThinking(); - break; - } - default: break; } }); diff --git a/src/opensensa/web/static/css/chat.css b/src/opensensa/web/static/css/chat.css index 090a11b..15f3c21 100644 --- a/src/opensensa/web/static/css/chat.css +++ b/src/opensensa/web/static/css/chat.css @@ -42,9 +42,20 @@ color: var(--text-muted); } -.empty-state svg { opacity: .2; stroke: var(--text-faint); } -.empty-state p { font-size: 15px; font-weight: 600; color: var(--text-mid); } -.empty-state span { font-size: 13px; } +.empty-state svg { + opacity: .2; + stroke: var(--text-faint); +} + +.empty-state p { + font-size: 15px; + font-weight: 600; + color: var(--text-mid); +} + +.empty-state span { + font-size: 13px; +} /* ── Primary Chat Panel ──────────────────────────────────── */ .chat-panel { @@ -60,8 +71,15 @@ } @keyframes panelSlideIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .chat-panel.panel-enter { @@ -69,8 +87,15 @@ } @keyframes panelEnter { - 0% { opacity: 0; transform: translateX(-16px) scale(.98); } - 100% { opacity: 1; transform: translateX(0) scale(1); } + 0% { + opacity: 0; + transform: translateX(-16px) scale(.98); + } + + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } } /* ── Chat Header ─────────────────────────────────────────── */ @@ -108,8 +133,17 @@ } @keyframes dotBreathe { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: .5; transform: scale(.75); } + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: .5; + transform: scale(.75); + } } .chat-agent-name { @@ -156,8 +190,14 @@ min-height: 0; } -.chat-messages::-webkit-scrollbar { width: 4px; } -.chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} .chat-messages-inner { padding: 16px 20px; @@ -178,8 +218,15 @@ } @keyframes msgUp { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .chat-msg.user { @@ -197,8 +244,13 @@ border: 1px solid var(--border-light); } -.chat-msg.agent p { margin: 0 0 .3em; } -.chat-msg.agent p:last-child { margin: 0; } +.chat-msg.agent p { + margin: 0 0 .3em; +} + +.chat-msg.agent p:last-child { + margin: 0; +} .chat-msg.agent pre { background: #2C2B27; @@ -211,7 +263,10 @@ font-family: var(--mono); } -.chat-msg.agent code { font-family: var(--mono); font-size: .88em; } +.chat-msg.agent code { + font-family: var(--mono); + font-size: .88em; +} .chat-msg.agent :not(pre)>code { background: rgba(55, 53, 47, .06); @@ -258,12 +313,27 @@ animation: dots .7s ease-in-out infinite; } -.chat-thinking span:nth-child(2) { animation-delay: .12s; } -.chat-thinking span:nth-child(3) { animation-delay: .24s; } +.chat-thinking span:nth-child(2) { + animation-delay: .12s; +} + +.chat-thinking span:nth-child(3) { + animation-delay: .24s; +} @keyframes dots { - 0%, 80%, 100% { transform: scale(.5); opacity: .3; } - 40% { transform: scale(1); opacity: 1; } + + 0%, + 80%, + 100% { + transform: scale(.5); + opacity: .3; + } + + 40% { + transform: scale(1); + opacity: 1; + } } /* ── Chat Activity (Tool Chips) ──────────────────────────── */ @@ -292,8 +362,15 @@ } @keyframes chipPop { - from { opacity: 0; transform: scale(.95); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(.95); + } + + to { + opacity: 1; + transform: scale(1); + } } .tool-chip.running { @@ -314,7 +391,9 @@ border: 1px solid #F5D0CD; } -.tool-chip-icon { font-size: 11px; } +.tool-chip-icon { + font-size: 11px; +} .spinner { width: 9px; @@ -326,7 +405,9 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ── Context Headers Bar ─────────────────────────────────── */ @@ -340,7 +421,9 @@ flex-shrink: 0; } -.context-headers-bar.hidden { display: none; } +.context-headers-bar.hidden { + display: none; +} .ctx-header-field { display: flex; @@ -372,8 +455,14 @@ transition: border-color .15s; } -.ctx-header-input:focus { border-color: var(--accent); } -.ctx-header-input::placeholder { color: var(--text-faint); font-size: 11px; } +.ctx-header-input:focus { + border-color: var(--accent); +} + +.ctx-header-input::placeholder { + color: var(--text-faint); + font-size: 11px; +} /* ── Chat Input ──────────────────────────────────────────── */ .chat-input-area { @@ -398,7 +487,10 @@ transition: border-color .15s, box-shadow .15s; } -.chat-input::placeholder { color: var(--text-faint); font-size: 13px; } +.chat-input::placeholder { + color: var(--text-faint); + font-size: 13px; +} .chat-input:focus { border-color: var(--accent); @@ -420,6 +512,16 @@ flex-shrink: 0; } -.chat-send-btn svg { width: 15px; height: 15px; } -.chat-send-btn:hover { background: #2C2B27; } -.chat-send-btn:disabled { opacity: .25; cursor: not-allowed; } +.chat-send-btn svg { + width: 15px; + height: 15px; +} + +.chat-send-btn:hover { + background: #2C2B27; +} + +.chat-send-btn:disabled { + opacity: .25; + cursor: not-allowed; +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/delegation-tree.css b/src/opensensa/web/static/css/delegation-tree.css index ba28575..0f5d6f9 100644 --- a/src/opensensa/web/static/css/delegation-tree.css +++ b/src/opensensa/web/static/css/delegation-tree.css @@ -25,7 +25,9 @@ transition: opacity .3s ease; } -.tree-link-arrow.visible { opacity: 1; } +.tree-link-arrow.visible { + opacity: 1; +} /* ── Tree Panel Container ────────────────────────────────── */ .tree-panel { @@ -67,8 +69,14 @@ padding: 12px 10px 20px; } -.tree-panel .delegation-tree::-webkit-scrollbar { width: 4px; } -.tree-panel .delegation-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.tree-panel .delegation-tree::-webkit-scrollbar { + width: 4px; +} + +.tree-panel .delegation-tree::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} /* ── Delegation Tree Layout ──────────────────────────────── */ .delegation-tree { @@ -87,8 +95,15 @@ } @keyframes delegationSlideIn { - from { opacity: 0; transform: translateY(-12px) scale(.97); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(-12px) scale(.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } } /* Vertical connector */ @@ -166,11 +181,22 @@ } @keyframes dotBreathe { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: .5; transform: scale(.75); } + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: .5; + transform: scale(.75); + } } -.delegation-card.completed .delegation-card-dot { background: var(--success); } +.delegation-card.completed .delegation-card-dot { + background: var(--success); +} .delegation-card-name { font-size: 12.5px; @@ -193,13 +219,26 @@ gap: 5px; } -.delegation-card-messages::-webkit-scrollbar { width: 3px; } -.delegation-card-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +.delegation-card-messages::-webkit-scrollbar { + width: 3px; +} + +.delegation-card-messages::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} -.delegation-card-activity { padding: 0 14px; } -.delegation-card-activity:not(:empty) { padding: 4px 14px 8px; } +.delegation-card-activity { + padding: 0 14px; +} -.delegation-node .delegation-node { margin-top: 0; } +.delegation-card-activity:not(:empty) { + padding: 4px 14px 8px; +} + +.delegation-node .delegation-node { + margin-top: 0; +} /* ── Tool chips (used inside delegation cards) ───────────── */ /* Inherits .tool-chip, .spinner from chat.css if present; @@ -233,7 +272,9 @@ border: 1px solid #F5D0CD; } -.delegation-card .tool-chip-icon { font-size: 11px; } +.delegation-card .tool-chip-icon { + font-size: 11px; +} .delegation-card .spinner { width: 9px; @@ -245,7 +286,9 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ── Delegation From Label ───────────────────────────────── */ @@ -257,4 +300,4 @@ display: flex; align-items: center; gap: 4px; -} +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/modal.css b/src/opensensa/web/static/css/modal.css index 7925062..d89caa1 100644 --- a/src/opensensa/web/static/css/modal.css +++ b/src/opensensa/web/static/css/modal.css @@ -20,8 +20,13 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } .modal { @@ -36,8 +41,15 @@ } @keyframes modalSlide { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .modal-header { @@ -73,9 +85,13 @@ color: var(--error); } -.modal-body { padding: 18px 22px; } +.modal-body { + padding: 18px 22px; +} -.form-group { margin-bottom: 14px; } +.form-group { + margin-bottom: 14px; +} .form-group label { display: block; @@ -143,7 +159,9 @@ color: var(--surface); } -.btn-primary:hover { background: #2C2B27; } +.btn-primary:hover { + background: #2C2B27; +} .btn-ghost { background: none; @@ -160,4 +178,6 @@ color: #fff; } -.btn-danger:hover { background: #B04840; } +.btn-danger:hover { + background: #B04840; +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/sidebar.css b/src/opensensa/web/static/css/sidebar.css index 5a12472..33a5a11 100644 --- a/src/opensensa/web/static/css/sidebar.css +++ b/src/opensensa/web/static/css/sidebar.css @@ -50,8 +50,13 @@ } @keyframes spin-once { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } .sidebar-title { @@ -68,8 +73,14 @@ padding: 0 8px 12px; } -.sidebar-list::-webkit-scrollbar { width: 4px; } -.sidebar-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +.sidebar-list::-webkit-scrollbar { + width: 4px; +} + +.sidebar-list::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} /* ── Sidebar Collapsed State ─────────────────────────────── */ .sidebar.collapsed { @@ -85,7 +96,9 @@ justify-content: center; } -.sidebar.collapsed .sidebar-refresh-btn { display: none; } +.sidebar.collapsed .sidebar-refresh-btn { + display: none; +} .sidebar.collapsed .sidebar-title { font-size: 0; @@ -108,7 +121,9 @@ box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); } -.sidebar.collapsed .sidebar-list { display: none; } +.sidebar.collapsed .sidebar-list { + display: none; +} /* Click-expanded overlay */ .sidebar.collapsed.expanded { @@ -129,15 +144,22 @@ height: auto; } -.sidebar.collapsed.expanded .sidebar-title::after { display: none; } -.sidebar.collapsed.expanded .sidebar-refresh-btn { display: flex; } +.sidebar.collapsed.expanded .sidebar-title::after { + display: none; +} + +.sidebar.collapsed.expanded .sidebar-refresh-btn { + display: flex; +} .sidebar.collapsed.expanded .sidebar-list { display: block; padding: 0 8px 12px; } -.sidebar.collapsed .sidebar-header { cursor: pointer; } +.sidebar.collapsed .sidebar-header { + cursor: pointer; +} /* ── Sidebar Agent Entry ─────────────────────────────────── */ .sidebar-agent { @@ -153,8 +175,13 @@ user-select: none; } -.sidebar-agent:active { transform: scale(.98); } -.sidebar-agent:hover { background: var(--surface-hover); } +.sidebar-agent:active { + transform: scale(.98); +} + +.sidebar-agent:hover { + background: var(--surface-hover); +} .sidebar-agent.active { background: var(--accent-bg); @@ -163,12 +190,24 @@ } @keyframes sidebarSelect { - from { background: var(--surface-hover); transform: translateX(0); } - 40% { transform: translateX(4px); } - to { background: var(--accent-bg); transform: translateX(0); } + from { + background: var(--surface-hover); + transform: translateX(0); + } + + 40% { + transform: translateX(4px); + } + + to { + background: var(--accent-bg); + transform: translateX(0); + } } -.sidebar-agent.delegating { background: var(--success-bg); } +.sidebar-agent.delegating { + background: var(--success-bg); +} .sidebar-agent-dot { width: 7px; @@ -180,8 +219,13 @@ transition: background .3s; } -.sidebar-agent.active .sidebar-agent-dot { background: var(--accent); } -.sidebar-agent.delegating .sidebar-agent-dot { background: var(--success); } +.sidebar-agent.active .sidebar-agent-dot { + background: var(--accent); +} + +.sidebar-agent.delegating .sidebar-agent-dot { + background: var(--success); +} .sidebar-agent-info { flex: 1; @@ -223,9 +267,11 @@ flex-shrink: 0; } -.sidebar-agent:hover .sidebar-agent-edit { opacity: 1; } +.sidebar-agent:hover .sidebar-agent-edit { + opacity: 1; +} .sidebar-agent-edit:hover { background: var(--surface-alt); color: var(--text-mid); -} +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/tokens.css b/src/opensensa/web/static/css/tokens.css index eeeea15..6fb9eca 100644 --- a/src/opensensa/web/static/css/tokens.css +++ b/src/opensensa/web/static/css/tokens.css @@ -81,4 +81,4 @@ box-sizing: border-box; margin: 0; padding: 0; -} +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/toolbar.css b/src/opensensa/web/static/css/toolbar.css index 0a39f80..f343693 100644 --- a/src/opensensa/web/static/css/toolbar.css +++ b/src/opensensa/web/static/css/toolbar.css @@ -80,4 +80,4 @@ background: #2C2B27; border-color: #2C2B27; color: var(--surface); -} +} \ No newline at end of file diff --git a/src/opensensa/web/static/css/utilities.css b/src/opensensa/web/static/css/utilities.css index 03b34f6..40da96b 100644 --- a/src/opensensa/web/static/css/utilities.css +++ b/src/opensensa/web/static/css/utilities.css @@ -5,4 +5,4 @@ /* ── Utilities ───────────────────────────────────────────── */ .hidden { display: none !important; -} +} \ No newline at end of file diff --git a/src/opensensa/web/static/modules/agent-modal.js b/src/opensensa/web/static/modules/agent-modal.js index c3e0fa5..75b4a23 100644 --- a/src/opensensa/web/static/modules/agent-modal.js +++ b/src/opensensa/web/static/modules/agent-modal.js @@ -104,15 +104,15 @@ export class AgentModal { this._overlay.innerHTML = MODAL_TEMPLATE; containerEl.appendChild(this._overlay); - const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */ (this._overlay.querySelector(`[data-ref="${n}"]`)); - this._title = ref("modal-title"); - this._form = /** @type {HTMLFormElement} */ (ref("agent-form")); - this._afName = /** @type {HTMLInputElement} */ (ref("af-name")); - this._afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); + const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */(this._overlay.querySelector(`[data-ref="${n}"]`)); + this._title = ref("modal-title"); + this._form = /** @type {HTMLFormElement} */ (ref("agent-form")); + this._afName = /** @type {HTMLInputElement} */ (ref("af-name")); + this._afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); this._afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); - this._afModel = /** @type {HTMLInputElement} */ (ref("af-model")); - this._afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); - this._afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); + this._afModel = /** @type {HTMLInputElement} */ (ref("af-model")); + this._afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); + this._afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); this._afSubmit = ref("af-submit"); this._afDelete = ref("af-delete"); diff --git a/src/opensensa/web/static/modules/api.js b/src/opensensa/web/static/modules/api.js index 21737f5..a95df57 100644 --- a/src/opensensa/web/static/modules/api.js +++ b/src/opensensa/web/static/modules/api.js @@ -22,7 +22,6 @@ * @property {(body: any) => Promise} createAgent * @property {(name: string, body: any) => Promise} updateAgent * @property {(name: string) => Promise} deleteAgent - * @property {(agentName: string) => Promise} createSession */ /** @@ -68,14 +67,5 @@ export function createApi(baseUrl = "") { const res = await request(`/api/agents/${name}`, { method: "DELETE" }); return res.json(); }, - - async createSession(agentName) { - const res = await request("/api/chat/sessions", { - method: "POST", - body: JSON.stringify({ agent_name: agentName }), - }); - const data = await res.json(); - return data.session_id; - }, }; } diff --git a/src/opensensa/web/static/modules/chat-panel.js b/src/opensensa/web/static/modules/chat-panel.js index e49b8f1..7d75d0d 100644 --- a/src/opensensa/web/static/modules/chat-panel.js +++ b/src/opensensa/web/static/modules/chat-panel.js @@ -103,11 +103,12 @@ export class ChatPanel { // State /** @type {string|null} */ _agentName = null; - /** @type {string|null} */ _sessionId = null; /** @type {ChatMessage[]} */ _messages = []; /** @type {boolean} */ _isSending = false; /** @type {HTMLElement|null} */ _thinkingEl = null; /** @type {Map} */ _activeTools = new Map(); + /** @type {HTMLElement|null} */ _streamingEl = null; + /** @type {string} */ _streamingText = ""; /** @type {string[]} */ _contextHeaders = []; /** @type {Record} */ _handlers = {}; /** @type {Array<[EventTarget, string, EventListener]>} */ _listeners = []; @@ -136,18 +137,18 @@ export class ChatPanel { this._container.innerHTML = CHAT_TEMPLATE; - const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */ (this._container.querySelector(`[data-ref="${n}"]`)); - this._emptyState = ref("empty-state"); - this._chatPanel = ref("chat-panel"); - this._chatHeader = ref("chat-header"); - this._chatAgentName = ref("chat-agent-name"); - this._chatAgentModel = ref("chat-agent-model"); - this._chatMessages = ref("chat-messages"); + const ref = (/** @type {string} */ n) => /** @type {HTMLElement} */(this._container.querySelector(`[data-ref="${n}"]`)); + this._emptyState = ref("empty-state"); + this._chatPanel = ref("chat-panel"); + this._chatHeader = ref("chat-header"); + this._chatAgentName = ref("chat-agent-name"); + this._chatAgentModel = ref("chat-agent-model"); + this._chatMessages = ref("chat-messages"); this._chatMessagesInner = ref("chat-messages-inner"); - this._chatActivity = ref("chat-activity"); + this._chatActivity = ref("chat-activity"); this._contextHeadersBar = ref("context-headers-bar"); - this._chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); - this._chatSendBtn = ref("chat-send-btn"); + this._chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); + this._chatSendBtn = ref("chat-send-btn"); // Bind events this._on(this._chatInput, "keydown", (/** @type {KeyboardEvent} */ e) => { @@ -168,11 +169,12 @@ export class ChatPanel { */ setAgent(agent, restore) { this._agentName = agent.name; - this._sessionId = null; this._messages = restore?.messages ? [...restore.messages] : []; this._isSending = false; this._thinkingEl = null; this._activeTools.clear(); + this._streamingEl = null; + this._streamingText = ""; this._contextHeaders = agent.contextHeaders || []; this._emptyState.classList.add("hidden"); @@ -226,21 +228,58 @@ export class ChatPanel { this.addMessage("user", msg); try { - const sid = await this._ensureSession(); this.showThinking(); - const extraHeaders = this._getContextHeaderValues(); - const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; - - const res = await fetch(this._baseUrl + `/api/chat/sessions/${sid}/messages`, { - method: "POST", - headers: allHeaders, - body: JSON.stringify({ message: msg }), - }); + // Build A2A JSON-RPC message/stream request + const contextHeaders = this._getContextHeaderValues(); + const msgId = crypto.randomUUID(); + + // Build history from prior messages for multi-turn + /** @type {any[]} */ + const history = this._messages.slice(0, -1).map(m => ({ + role: m.role === "agent" ? "agent" : m.role, + parts: [{ kind: "text", text: m.content }], + })); + + /** @type {any} */ + const params = { + message: { + role: "user", + parts: [{ kind: "text", text: msg }], + messageId: msgId, + }, + }; + + // Attach history and context headers as metadata + /** @type {any} */ + const metadata = {}; + if (history.length > 0) metadata["framework:history"] = history; + if (Object.keys(contextHeaders).length > 0) metadata["context_headers"] = contextHeaders; + if (Object.keys(metadata).length > 0) params.metadata = metadata; + + const payload = { + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: "message/stream", + params, + }; + + const res = await fetch( + this._baseUrl + `/agents/${this._agentName}/`, + { + method: "POST", + headers: { "Content-Type": "application/json", ...contextHeaders }, + body: JSON.stringify(payload), + }, + ); const reader = res.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; + /** @type {number} */ + let toolCalls = 0; + /** @type {number} */ + let delegations = 0; if (reader) { while (true) { @@ -254,16 +293,37 @@ export class ChatPanel { const raw = line.slice(6); if (raw === "[DONE]") continue; try { - const evt = JSON.parse(raw); - this._handleInternalSSE(evt); - this._emit("sseEvent", evt); + const event = JSON.parse(raw); + const parsed = this._handleA2AEvent(event); + if (parsed) { + if (parsed.toolCalls) toolCalls += parsed.toolCalls; + if (parsed.delegations) delegations += parsed.delegations; + this._emit("sseEvent", parsed); + } } catch { /* skip malformed */ } } } } + + // Finalize the streamed response + const response = this._streamingText; + this.hideThinking(); + this._finalizeStreamingMessage(); + if (!response) { + this.addMessage("agent", "Agent completed with no output."); + } + this._emit("sseEvent", { + event: "turn_complete", + response, + stats: { tool_calls: toolCalls, delegations }, + }); } catch (err) { this.hideThinking(); this.addMessage("error", "Network error: " + /** @type {Error} */ (err).message); + this._emit("sseEvent", { + event: "turn_error", + error: /** @type {Error} */ (err).message, + }); } this._isSending = false; @@ -320,13 +380,14 @@ export class ChatPanel { } } - /** Reset the session (clears history, gets new session on next send). */ + /** Reset the conversation (clears history). */ reset() { - this._sessionId = null; this._messages = []; this._chatMessagesInner.innerHTML = ""; this._chatActivity.innerHTML = ""; this._activeTools.clear(); + this._streamingEl = null; + this._streamingText = ""; this.hideThinking(); } @@ -389,17 +450,121 @@ export class ChatPanel { this._chatMessagesInner.appendChild(div); } - /** @returns {Promise} */ - async _ensureSession() { - if (this._sessionId) return this._sessionId; - const res = await fetch(this._baseUrl + "/api/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agent_name: this._agentName }), - }); - const data = await res.json(); - this._sessionId = data.session_id; - return /** @type {string} */ (this._sessionId); + /** + * Parse an A2A SSE event (JSON-RPC result) into a normalized event + * for the delegation tree and chat panel. + * + * A2A SSE events have this shape: + * { "result": { "kind": "status-update"|"artifact-update", ... } } + * + * Returns a normalized event object or null if unhandled. + * + * @param {any} event — raw parsed JSON from SSE data line + * @returns {any|null} + */ + _handleA2AEvent(event) { + const result = event?.result; + if (!result) return null; + const kind = result.kind; + + if (kind === "status-update") { + const msg = result.status?.message || {}; + const meta = msg.metadata || {}; + const fwEvent = meta["framework:event"]; + + if (fwEvent === "tool_start") { + const tool = meta["framework:tool"] || "tool"; + const nodeId = `tool:${Date.now()}`; + const isDelegation = tool === "delegate"; + if (!isDelegation) { + this.showToolChip(nodeId, tool, "running"); + } + return { + event: "tool_start", + node_id: nodeId, + tool, + toolCalls: isDelegation ? 0 : 1, + delegations: isDelegation ? 1 : 0, + }; + } + + if (fwEvent === "tool_end") { + const tool = meta["framework:tool"] || "tool"; + return { event: "tool_end", tool }; + } + + if (fwEvent === "delegation_start") { + return { + event: "delegation_start", + node_id: `del:${Date.now()}`, + from_agent: meta["framework:from_agent"] || "", + to_agent: meta["framework:to_agent"] || "", + message: meta["framework:message"] || "", + }; + } + + if (fwEvent === "delegation_end") { + return { + event: "delegation_end", + to_agent: meta["framework:to_agent"] || "", + response: meta["framework:response"] || "", + }; + } + + return null; + } + + if (kind === "artifact-update") { + const artifact = result.artifact || {}; + const aname = artifact.name || ""; + // Skip execution-trace artifacts + if (aname.endsWith("-execution-trace")) return null; + // Stream text parts into the live message bubble + for (const part of artifact.parts || []) { + if (part.kind === "text" && part.text) { + this._appendStreamingChunk(part.text); + } + } + return null; + } + + return null; + } + + /** + * Append a text chunk to the in-progress streaming message bubble. + * Creates the bubble on first call, hides thinking dots. + * @param {string} chunk + */ + _appendStreamingChunk(chunk) { + if (!this._streamingEl) { + this.hideThinking(); + const div = document.createElement("div"); + div.className = "chat-msg agent streaming"; + this._chatMessagesInner.appendChild(div); + this._streamingEl = div; + this._streamingText = ""; + } + this._streamingText += chunk; + // Render plain text while streaming (markdown applied on finalize) + this._streamingEl.textContent = this._streamingText; + this._chatMessages.scrollTop = this._chatMessages.scrollHeight; + } + + /** + * Finalize the streaming message — parse markdown, save to history. + */ + _finalizeStreamingMessage() { + if (!this._streamingEl || !this._streamingText) return; + const text = this._streamingText; + // Apply markdown rendering + this._streamingEl.classList.remove("streaming"); + this._streamingEl.innerHTML = this._marked ? this._marked.parse(text) : text; + // Save to conversation history + this._messages.push({ role: "agent", content: text }); + this._streamingEl = null; + this._streamingText = ""; + this._chatMessages.scrollTop = this._chatMessages.scrollHeight; } _renderContextHeaders() { @@ -441,29 +606,6 @@ export class ChatPanel { return headers; } - /** Handle SSE events that affect chat directly (turn_complete, turn_error, llm_start/end, tool chips in primary). */ - /** @param {any} evt */ - _handleInternalSSE(evt) { - switch (evt.event) { - case "turn_complete": - this.hideThinking(); - if (evt.response) this.addMessage("agent", evt.response); - break; - case "turn_error": - this.hideThinking(); - this.addMessage("error", evt.error || "Unknown error"); - break; - case "llm_start": - // Only show thinking for primary agent (no delegation parent) - // The orchestrator handles delegation-scoped events - break; - case "llm_end": - break; - default: - break; - } - } - _clearToolChips() { this._activeTools.clear(); setTimeout(() => { this._chatActivity.innerHTML = ""; }, 2000); diff --git a/src/opensensa/web/static/modules/sidebar.js b/src/opensensa/web/static/modules/sidebar.js index 6c36e0f..040b076 100644 --- a/src/opensensa/web/static/modules/sidebar.js +++ b/src/opensensa/web/static/modules/sidebar.js @@ -85,7 +85,7 @@ export class Sidebar { // Close expanded sidebar when clicking outside this._on(document, "click", (/** @type {MouseEvent} */ e) => { if (!this._sidebarEl.classList.contains("expanded")) return; - if (!this._sidebarEl.contains(/** @type {Node} */ (e.target))) { + if (!this._sidebarEl.contains(/** @type {Node} */(e.target))) { this._sidebarEl.classList.remove("expanded"); } }); diff --git a/src/opensensa/web/static/styles.css b/src/opensensa/web/static/styles.css index 091d141..c1c922d 100644 --- a/src/opensensa/web/static/styles.css +++ b/src/opensensa/web/static/styles.css @@ -30,4 +30,4 @@ @import url("css/chat.css"); @import url("css/delegation-tree.css"); @import url("css/modal.css"); -@import url("css/utilities.css"); +@import url("css/utilities.css"); \ No newline at end of file diff --git a/tests/test_framework_tools.py b/tests/test_framework_tools.py index 48d8cf2..35cbbfa 100644 --- a/tests/test_framework_tools.py +++ b/tests/test_framework_tools.py @@ -16,7 +16,7 @@ """Tests for framework-provided MCP tools. -Tests: create_agent, edit_agent, delete_agent, delegate, discover_agents, send_to_agent, list_tools. +Tests: create_agent, edit_agent, delete_agent, delegate, discover_agents, list_tools. """ import re @@ -238,56 +238,73 @@ async def test_discover_remote_agent_failure(self, fake_mcp, registry): # --------------------------------------------------------------------------- -# send_to_agent tests +# delegate by URL tests (replaces send_to_agent) # --------------------------------------------------------------------------- -class TestSendToAgent: +class TestDelegateByUrl: + """Tests for URL-based delegation (the unified delegate tool).""" + @pytest.mark.asyncio - async def test_depth_limit_enforced(self, fake_mcp): - from opensensa.framework_tools.send_to_agent import register, MAX_A2A_DEPTH - register(fake_mcp) + async def test_delegate_by_url_depth_limit(self): + from opensensa.framework_tools.delegate import _delegate_impl, MAX_DELEGATION_DEPTH - send_fn = fake_mcp.get_tool("send_to_agent") - result = await send_fn( - agent_url="http://localhost:9000", + result_json = await _delegate_impl( message="Hello", - current_depth=MAX_A2A_DEPTH, + agent_url="http://localhost:9000/agents/remote", + current_depth=MAX_DELEGATION_DEPTH, ) + import json + result = json.loads(result_json) assert result["status"] == "error" assert "depth limit" in result["error"].lower() @pytest.mark.asyncio - async def test_depth_below_limit_proceeds(self, fake_mcp): - """Depth below limit should attempt the request (will fail on connection).""" - from opensensa.framework_tools.send_to_agent import register - register(fake_mcp) + async def test_delegate_by_url_attempts_connection(self): + """URL-based delegation below depth limit should attempt the request.""" + from opensensa.framework_tools.delegate import _delegate_impl - send_fn = fake_mcp.get_tool("send_to_agent") - result = await send_fn( - agent_url="http://127.0.0.1:1", # unreachable + result_json = await _delegate_impl( message="Hello", + agent_url="http://127.0.0.1:1/agents/remote", current_depth=0, ) + import json + result = json.loads(result_json) # Should get a connection error, not a depth error assert result["status"] == "error" assert "depth limit" not in result.get("error", "").lower() @pytest.mark.asyncio - async def test_timeout_handling(self, fake_mcp): - """Timeout should return a clean error.""" - from opensensa.framework_tools.send_to_agent import register - register(fake_mcp) + async def test_delegate_by_url_skips_allowlist(self): + """URL-based delegation should not require an allowlist.""" + from opensensa.framework_tools.delegate import _delegate_impl - send_fn = fake_mcp.get_tool("send_to_agent") - # Use unreachable address — will either timeout or connection error - result = await send_fn( - agent_url="http://192.0.2.1", # TEST-NET, guaranteed unroutable + result_json = await _delegate_impl( message="Hello", + agent_url="http://127.0.0.1:1/agents/remote", + allowed_sub_agents=None, + current_depth=0, ) + import json + result = json.loads(result_json) + # Should try to connect (and fail), not reject on allowlist + assert result["status"] == "error" + assert "sub_agents" not in result.get("error", "") + + @pytest.mark.asyncio + async def test_delegate_requires_name_or_url(self): + """Delegation with neither name nor URL should error.""" + from opensensa.framework_tools.delegate import _delegate_impl + + result_json = await _delegate_impl(message="Hello") + + import json + result = json.loads(result_json) assert result["status"] == "error" + assert "agent_name" in result["error"] or "agent_url" in result["error"] # --------------------------------------------------------------------------- @@ -356,8 +373,8 @@ async def test_delegate_depth_limit(self, registry): from opensensa.framework_tools.delegate import _delegate_impl, MAX_DELEGATION_DEPTH result_json = await _delegate_impl( - agent_name="existing-agent", message="Hello", + agent_name="existing-agent", allowed_sub_agents=["existing-agent"], agent_registry=registry, local_base_url="http://localhost:8000", @@ -374,8 +391,8 @@ async def test_delegate_unknown_agent(self, registry): from opensensa.framework_tools.delegate import _delegate_impl result_json = await _delegate_impl( - agent_name="nonexistent-agent", message="Hello", + agent_name="nonexistent-agent", allowed_sub_agents=["nonexistent-agent"], agent_registry=registry, local_base_url="http://localhost:8000", @@ -392,8 +409,8 @@ async def test_delegate_rejects_unlisted_agent(self, registry): from opensensa.framework_tools.delegate import _delegate_impl result_json = await _delegate_impl( - agent_name="existing-agent", message="Hello", + agent_name="existing-agent", allowed_sub_agents=["some-other-agent"], agent_registry=registry, local_base_url="http://localhost:8000", @@ -410,8 +427,8 @@ async def test_delegate_resolves_local_agent(self, registry): from opensensa.framework_tools.delegate import _delegate_impl result_json = await _delegate_impl( - agent_name="existing-agent", message="Hello", + agent_name="existing-agent", allowed_sub_agents=["existing-agent"], agent_registry=registry, local_base_url="http://127.0.0.1:1", @@ -424,6 +441,25 @@ async def test_delegate_resolves_local_agent(self, registry): assert result["status"] == "error" assert "not found" not in result["error"].lower() + @pytest.mark.asyncio + async def test_delegate_without_allowlist(self, registry): + """Delegation by name without an allowlist should resolve and attempt connection.""" + from opensensa.framework_tools.delegate import _delegate_impl + + result_json = await _delegate_impl( + message="Hello", + agent_name="existing-agent", + allowed_sub_agents=None, + agent_registry=registry, + local_base_url="http://127.0.0.1:1", + ) + + import json + result = json.loads(result_json) + # Should attempt connection, not reject on allowlist + assert result["status"] == "error" + assert "sub_agents" not in result.get("error", "") + def test_build_delegate_tool_returns_function_tool(self): """build_delegate_tool should return a FunctionTool with correct metadata.""" from opensensa.framework_tools.delegate import build_delegate_tool @@ -437,6 +473,19 @@ def test_build_delegate_tool_returns_function_tool(self): assert "analyst" in tool.description assert "writer" in tool.description + def test_build_delegate_tool_without_sub_agents(self): + """build_delegate_tool with no sub_agents should still return a working tool.""" + from opensensa.framework_tools.delegate import build_delegate_tool + from agents import FunctionTool + + agent_def = self._make_agent_def(sub_agents=[]) + tool = build_delegate_tool(agent_def) + + assert isinstance(tool, FunctionTool) + assert tool.name == "delegate" + # Should mention URL-based delegation + assert "url" in tool.description.lower() or "discover" in tool.description.lower() + # --------------------------------------------------------------------------- # sub_agents in AgentDefinition tests From 53307e94e36a261da0a2b860fb7d7c537fdd7e10 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Fri, 20 Mar 2026 12:38:54 -0400 Subject: [PATCH 10/13] removed superfluous agents dir --- .gitignore | 5 +++++ agents/test-agent.md | 17 ----------------- 2 files changed, 5 insertions(+), 17 deletions(-) delete mode 100644 agents/test-agent.md diff --git a/.gitignore b/.gitignore index b7faf40..744a42e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# User workspace (created by `opensensa init`) +agents/ +tools/ +opensensa.yaml + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] diff --git a/agents/test-agent.md b/agents/test-agent.md deleted file mode 100644 index f019031..0000000 --- a/agents/test-agent.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: test-agent -description: A simple test agent -model: ${default} -tools: [] -skills: - - id: test-skill - name: Test Skill - description: Just a test - tags: [test] -input_modes: ["text/plain"] -output_modes: ["text/plain"] ---- - -# Test Agent - -You are a test agent. From 3a102cb876e95ba16a1ed029855ed901c34b2bb4 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Fri, 20 Mar 2026 13:07:20 -0400 Subject: [PATCH 11/13] more dead code removed --- src/opensensa/agents/__init__.py | 9 - src/opensensa/mcp_server/server.py | 19 - src/opensensa/mcp_server/tool_loader.py | 9 - .../orchestrator/prompts/__init__.py | 17 - .../orchestrator/prompts/default.txt | 1 - src/opensensa/orchestrator/tracing.py | 15 - src/opensensa/orchestrator/validators.py | 48 - src/opensensa/web/static/app.js.bak | 969 ------------ src/opensensa/web/static/styles.css.bak | 1354 ----------------- tests/test_agent_registry.py | 2 +- tests/test_config.py | 6 +- tests/test_framework_tools.py | 7 +- 12 files changed, 10 insertions(+), 2446 deletions(-) delete mode 100644 src/opensensa/orchestrator/prompts/__init__.py delete mode 100644 src/opensensa/orchestrator/prompts/default.txt delete mode 100644 src/opensensa/orchestrator/validators.py delete mode 100644 src/opensensa/web/static/app.js.bak delete mode 100644 src/opensensa/web/static/styles.css.bak diff --git a/src/opensensa/agents/__init__.py b/src/opensensa/agents/__init__.py index 9cfc238..eb394fb 100644 --- a/src/opensensa/agents/__init__.py +++ b/src/opensensa/agents/__init__.py @@ -19,12 +19,3 @@ These are the default agents copied into user projects on `opensensa init`. The actual agent files live alongside this __init__.py as .md files. """ - -from pathlib import Path - -AGENTS_DIR = Path(__file__).parent - - -def list_bundled_agents() -> list[Path]: - """Return paths to all bundled agent .md files.""" - return sorted(AGENTS_DIR.glob("*.md")) diff --git a/src/opensensa/mcp_server/server.py b/src/opensensa/mcp_server/server.py index 0e110d0..caee250 100644 --- a/src/opensensa/mcp_server/server.py +++ b/src/opensensa/mcp_server/server.py @@ -28,25 +28,6 @@ _mcp_instance = None -def get_mcp(): - """Get the current MCP server instance. - - Tools import this to register themselves: - from opensensa.mcp_server.server import get_mcp - mcp = get_mcp() - - @mcp.tool(...) - def my_tool(...): - ... - """ - if _mcp_instance is None: - raise RuntimeError( - "MCP server not initialized. Call create_mcp_server() first, " - "or use `opensensa serve` which does this automatically." - ) - return _mcp_instance - - def create_mcp_server( host: str = "0.0.0.0", port: int = 8001, diff --git a/src/opensensa/mcp_server/tool_loader.py b/src/opensensa/mcp_server/tool_loader.py index 2146dc9..ea4c485 100644 --- a/src/opensensa/mcp_server/tool_loader.py +++ b/src/opensensa/mcp_server/tool_loader.py @@ -84,12 +84,3 @@ def discover_and_load_tools(tools_directory: str | Path, mcp_instance=None) -> l logger.info(f"Discovered {len(loaded)} tool module(s) from {tools_dir}") return loaded - - -def load_bundled_tools(mcp_instance=None) -> list[str]: - """Load the bundled example tools shipped with OpenSensa. - - These live in src/opensensa/tools/ and are always available unless disabled. - """ - bundled_dir = Path(__file__).parent.parent / "tools" - return discover_and_load_tools(bundled_dir, mcp_instance) diff --git a/src/opensensa/orchestrator/prompts/__init__.py b/src/opensensa/orchestrator/prompts/__init__.py deleted file mode 100644 index 18cd2fc..0000000 --- a/src/opensensa/orchestrator/prompts/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""Default and template prompts.""" diff --git a/src/opensensa/orchestrator/prompts/default.txt b/src/opensensa/orchestrator/prompts/default.txt deleted file mode 100644 index a191ab5..0000000 --- a/src/opensensa/orchestrator/prompts/default.txt +++ /dev/null @@ -1 +0,0 @@ -You are a helpful AI assistant. Answer the user's questions clearly and concisely. If you have access to tools, use them when they would help provide a better answer. diff --git a/src/opensensa/orchestrator/tracing.py b/src/opensensa/orchestrator/tracing.py index 5ff7173..23876f3 100644 --- a/src/opensensa/orchestrator/tracing.py +++ b/src/opensensa/orchestrator/tracing.py @@ -55,13 +55,6 @@ def complete(self, status: str = "completed", **extra_metadata): self.metadata.update(extra_metadata) self._emit_log() - def add_event(self, event_type: str, data: Any = None): - self.events.append({ - "type": event_type, - "timestamp_ms": int(time.time() * 1000), - "data": data, - }) - def _emit_log(self): """Emit structured JSON log for this span.""" entry = { @@ -124,11 +117,3 @@ def start_span( self._spans.append(span) self._active_span = span return span - - @property - def active_span(self) -> Optional[AgentSpan]: - return self._active_span - - @property - def spans(self) -> list[AgentSpan]: - return self._spans.copy() diff --git a/src/opensensa/orchestrator/validators.py b/src/opensensa/orchestrator/validators.py deleted file mode 100644 index 6e89871..0000000 --- a/src/opensensa/orchestrator/validators.py +++ /dev/null @@ -1,48 +0,0 @@ -# =========================================================================== -# Copyright (C) 2025 CapsicoHealth Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========================================================================== - -"""Request validation utilities.""" - -from typing import Any, Optional - - -class FrameworkError(Exception): - """Base exception for framework errors.""" - pass - - -class ValidationError(FrameworkError): - """Request validation error.""" - def __init__(self, message: str, field: Optional[str] = None): - self.field = field - super().__init__(message) - - -class AgentConfigurationError(FrameworkError): - """Agent configuration or setup error.""" - pass - - -class ModelNotFoundError(FrameworkError): - """Model reference could not be resolved.""" - pass - - -def validate_agent_name(name: str) -> str: - """Validate and normalize an agent name.""" - if not name or not name.strip(): - raise ValidationError("Agent name cannot be empty", field="name") - return name.strip() diff --git a/src/opensensa/web/static/app.js.bak b/src/opensensa/web/static/app.js.bak deleted file mode 100644 index b3376c1..0000000 --- a/src/opensensa/web/static/app.js.bak +++ /dev/null @@ -1,969 +0,0 @@ -/* =========================================================================== - * Copyright (C) 2025 CapsicoHealth Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================================================================== */ - -// @ts-check -/* ── OpenSensa · ES6 Module — mount(rootEl, options) ───────── */ - -/** - * @typedef {Object} OpenSensaOptions - * @property {string} [baseUrl=""] — API base URL prefix (e.g. "https://my-server.com") - * @property {string} [storagePrefix="opensensa"] — localStorage key prefix (for multi-instance) - * @property {any} [marked] — marked.js instance (falls back to window.marked) - * @property {any} [hljs] — highlight.js instance (falls back to window.hljs) - */ - -/** - * @typedef {{ - * name: string, description: string, model: string, - * tools: string[], sub_agents: string[], - * context_headers: string[], - * sidebarEl: HTMLElement|null, - * sessionId: string|null, - * messages: Array<{role:string, content:string}>, - * isSending: boolean, - * activeTools: Map, - * thinkingEl: HTMLElement|null, - * }} AgentState - * - * @typedef {{ - * id: string, from: string, to: string, - * nodeEl: HTMLElement|null, - * messagesEl: HTMLElement|null, - * activityEl: HTMLElement|null, - * response: string, - * thinkingEl: HTMLElement|null, - * activeTools: Map, - * children: DelegationState[], - * }} DelegationState - */ - -/* ════════════════════════════════════════════════════════════ - HTML Template (injected into rootEl) - ════════════════════════════════════════════════════════════ */ -const TEMPLATE = ` - -
-
- -
-
- -
-
- - -
- - - - -
-
- - - -

Select an agent to start chatting

- Or create a new agent with the button above -
- - -
- - - - - - -
- - -`; - -/* ════════════════════════════════════════════════════════════ - mount() — public entry point - ════════════════════════════════════════════════════════════ */ - -/** - * Mount the OpenSensa Agent Chat UI into a container element. - * - * @param {HTMLElement} rootEl — the container element (any div) - * @param {OpenSensaOptions} [options={}] - * @returns {{ destroy: () => void }} - */ -export default function mount(rootEl, options = {}) { - const baseUrl = (options.baseUrl || "").replace(/\/+$/, ""); - const storagePrefix = options.storagePrefix || "opensensa"; - const marked = options.marked || /** @type {any} */ (window)["marked"]; - const hljs = options.hljs || /** @type {any} */ (window)["hljs"]; - - // ── Inject HTML ────────────────────────────────────────── - rootEl.classList.add("root"); - rootEl.innerHTML = TEMPLATE; - - // ── Scoped query helper ────────────────────────────────── - const $ = (/** @type {string} */ s, /** @type {ParentNode} */ p = rootEl) => p.querySelector(s); - const ref = (/** @type {string} */ name) => /** @type {HTMLElement} */($(`[data-ref="${name}"]`)); - - // ── DOM Refs ───────────────────────────────────────────── - const sidebarList = ref("sidebar-list"); - const sidebar = /** @type {HTMLElement} */ ($(".sidebar")); - const emptyState = ref("empty-state"); - const primaryChat = ref("primary-chat"); - const chatHeader = ref("chat-header"); - const chatAgentName = ref("chat-agent-name"); - const chatAgentModel = ref("chat-agent-model"); - const chatMessages = ref("chat-messages"); - const chatMessagesInner = ref("chat-messages-inner"); - const chatActivity = ref("chat-activity"); - const contextHeadersBar = ref("context-headers-bar"); - const chatInput = /** @type {HTMLInputElement} */ (ref("chat-input")); - const chatSendBtn = ref("chat-send-btn"); - const treePanelHint = ref("tree-panel-hint"); - const treeLinkArrow = ref("tree-link-arrow"); - const delegationTree = ref("delegation-tree"); - const modalOverlay = ref("modal-overlay"); - const modalTitle = ref("modal-title"); - const agentForm = /** @type {HTMLFormElement} */ (ref("agent-form")); - const afName = /** @type {HTMLInputElement} */ (ref("af-name")); - const afDesc = /** @type {HTMLInputElement} */ (ref("af-desc")); - const afPrompt = /** @type {HTMLTextAreaElement} */ (ref("af-prompt")); - const afModel = /** @type {HTMLInputElement} */ (ref("af-model")); - const afTools = /** @type {HTMLInputElement} */ (ref("af-tools")); - const afSubs = /** @type {HTMLInputElement} */ (ref("af-subs")); - const afSubmit = ref("af-submit"); - const afDelete = ref("af-delete"); - const afCancel = ref("af-cancel"); - - // ── State ──────────────────────────────────────────────── - /** @type {Map} */ - const agents = new Map(); - /** @type {string|null} */ - let selectedAgent = null; - /** @type {Map} */ - const activeDelegations = new Map(); - /** @type {string|null} */ - let editingAgent = null; - let destroyed = false; - - // ── Helpers ────────────────────────────────────────────── - const api = (/** @type {string} */ path, /** @type {RequestInit} */ opts = {}) => - fetch(baseUrl + path, { headers: { "Content-Type": "application/json" }, ...opts }); - - marked.setOptions({ - highlight: (/** @type {string} */ code, /** @type {string} */ lang) => { - if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value; - return hljs.highlightAuto(code).value; - }, - breaks: true, - }); - - // ── Namespaced localStorage ────────────────────────────── - function storageGet(/** @type {string} */ key) { - try { return localStorage.getItem(`${storagePrefix}-${key}`); } catch { return null; } - } - function storageSet(/** @type {string} */ key, /** @type {string} */ val) { - try { localStorage.setItem(`${storagePrefix}-${key}`, val); } catch { } - } - - /* ════════════════════════════════════════════════════════════ - SIDEBAR — Load & Select Agents - ════════════════════════════════════════════════════════════ */ - - function createSidebarEntry(/** @type {{name:string, description:string, model:string}} */ agent) { - const el = document.createElement("div"); - el.className = "sidebar-agent"; - el.dataset.agent = agent.name; - el.innerHTML = ` - - - `; - - el.addEventListener("click", (e) => { - if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent-edit")) return; - selectAgent(agent.name); - }); - - const editBtn = $(".sidebar-agent-edit", el); - editBtn?.addEventListener("click", (e) => { - e.stopPropagation(); - openEditModal(agent.name); - }); - - return el; - } - - async function loadAgents() { - if (destroyed) return; - try { - const res = await api("/api/agents"); - const agentList = await res.json(); - console.log("[opensensa] loadAgents response:", agentList.map((/** @type {any} */ a) => ({ name: a.name, context_headers: a.context_headers }))); - - if (!Array.isArray(agentList) || agentList.length === 0) { - emptyState.classList.remove("hidden"); - primaryChat.classList.add("hidden"); - return; - } - - const existing = new Set(agents.keys()); - - agentList.forEach((/** @type {any} */ a) => { - if (agents.has(a.name)) { existing.delete(a.name); return; } - - const sidebarEl = createSidebarEntry(a); - sidebarList.appendChild(sidebarEl); - - /** @type {AgentState} */ - const state = { - name: a.name, - description: a.description || "", - model: a.model || "", - tools: a.tools || [], - sub_agents: a.sub_agents || [], - context_headers: a.context_headers || [], - sidebarEl, - sessionId: null, - messages: [], - isSending: false, - activeTools: new Map(), - thinkingEl: null, - }; - agents.set(a.name, state); - }); - - for (const gone of existing) { - const s = agents.get(gone); - if (s?.sidebarEl) s.sidebarEl.remove(); - agents.delete(gone); - } - - // No agent selected by default — user clicks to choose - } catch (err) { - console.error("opensensa loadAgents:", err); - } - } - - function selectAgent(/** @type {string} */ name) { - const agent = agents.get(name); - if (!agent) return; - - sidebar.classList.add("collapsed"); - sidebar.classList.remove("expanded"); - - selectedAgent = name; - storageSet("selected", name); - - for (const a of agents.values()) a.sidebarEl?.classList.toggle("active", a.name === name); - - emptyState.classList.add("hidden"); - primaryChat.classList.remove("hidden"); - - primaryChat.classList.remove("panel-enter"); - void primaryChat.offsetWidth; - primaryChat.classList.add("panel-enter"); - - chatAgentName.textContent = name; - chatAgentModel.textContent = agent.model || "default"; - - chatMessagesInner.innerHTML = ""; - for (const msg of agent.messages) appendMessageToEl(chatMessagesInner, msg.role, msg.content); - chatMessages.scrollTop = chatMessages.scrollHeight; - - chatActivity.innerHTML = ""; - delegationTree.innerHTML = ""; - updateTreePanelVisibility(); - - chatInput.placeholder = `Ask ${name}…`; - renderContextHeaders(agent); - chatInput.focus(); - } - - function updateTreePanelVisibility() { - const hasDelegations = delegationTree.children.length > 0; - treeLinkArrow.classList.toggle("visible", hasDelegations); - treePanelHint.style.display = hasDelegations ? "none" : "block"; - } - - /* ════════════════════════════════════════════════════════════ - CHAT — Messages + Sending - ════════════════════════════════════════════════════════════ */ - - function appendMessageToEl(/** @type {HTMLElement} */ container, /** @type {string} */ role, /** @type {string} */ content) { - const div = document.createElement("div"); - if (role === "user") { - div.className = "chat-msg user"; - div.textContent = content; - } else if (role === "agent") { - div.className = "chat-msg agent"; - div.innerHTML = marked.parse(content); - } else if (role === "error") { - div.className = "chat-msg error-msg"; - div.textContent = content; - } else { - div.className = "chat-msg system-msg"; - div.textContent = content; - } - container.appendChild(div); - } - - function addMessage(/** @type {string} */ role, /** @type {string} */ content) { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent) return; - agent.messages.push({ role, content }); - appendMessageToEl(chatMessagesInner, role, content); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function showThinking() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent || agent.thinkingEl) return; - const el = document.createElement("div"); - el.className = "chat-thinking"; - el.innerHTML = ""; - chatMessagesInner.appendChild(el); - agent.thinkingEl = el; - const dot = $(".chat-status-dot", chatHeader); - if (dot) dot.classList.add("thinking"); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function hideThinking() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent) return; - if (agent.thinkingEl) { agent.thinkingEl.remove(); agent.thinkingEl = null; } - const dot = $(".chat-status-dot", chatHeader); - if (dot) dot.classList.remove("thinking"); - } - - async function ensureSession(/** @type {AgentState} */ agent) { - if (agent.sessionId) return agent.sessionId; - const res = await api("/api/chat/sessions", { - method: "POST", body: JSON.stringify({ agent_name: agent.name }), - }); - const data = await res.json(); - agent.sessionId = data.session_id; - return agent.sessionId; - } - - /* ── Context-header inputs (e.g. X-Document-URL for healthbuddy) ── */ - - /** - * Render input fields above the chat input for each declared context_header. - * @param {AgentState} agent - */ - function renderContextHeaders(agent) { - contextHeadersBar.innerHTML = ""; - const keys = agent.context_headers || []; - console.log("[opensensa] renderContextHeaders", agent.name, "keys:", keys); - if (!keys.length) { - contextHeadersBar.classList.add("hidden"); - return; - } - contextHeadersBar.classList.remove("hidden"); - for (const headerName of keys) { - const label = headerName.replace(/^X-/i, "").replace(/-/g, " "); - const wrapper = document.createElement("div"); - wrapper.className = "ctx-header-field"; - wrapper.innerHTML = ``; - const input = document.createElement("input"); - input.type = "text"; - input.className = "ctx-header-input"; - input.placeholder = headerName; - input.dataset.headerName = headerName; - // Restore from localStorage if previously set - const stored = storageGet(`ctx:${agent.name}:${headerName}`); - if (stored) input.value = stored; - input.addEventListener("change", () => { - storageSet(`ctx:${agent.name}:${headerName}`, input.value); - }); - wrapper.appendChild(input); - contextHeadersBar.appendChild(wrapper); - } - } - - /** - * Read current context-header input values and return as a headers object. - * @returns {Record} - */ - function getContextHeaderValues() { - /** @type {Record} */ - const headers = {}; - contextHeadersBar.querySelectorAll("input.ctx-header-input").forEach((/** @type {HTMLInputElement} */ el) => { - const name = el.dataset.headerName; - if (name && el.value.trim()) headers[name] = el.value.trim(); - }); - console.log("[opensensa] getContextHeaderValues →", headers); - return headers; - } - - async function sendMessage() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (!agent || agent.isSending) return; - const text = chatInput.value.trim(); - if (!text) return; - - agent.isSending = true; - chatInput.value = ""; - addMessage("user", text); - - try { - const sid = await ensureSession(agent); - showThinking(); - - // Collect context headers from the UI inputs (e.g. X-Document-URL) - const extraHeaders = getContextHeaderValues(); - const allHeaders = { "Content-Type": "application/json", ...extraHeaders }; - console.log("[opensensa] sendMessage fetch headers:", allHeaders); - - const res = await fetch(baseUrl + `/api/chat/sessions/${sid}/messages`, { - method: "POST", - headers: allHeaders, - body: JSON.stringify({ message: text }), - }); - - const reader = res.body?.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - if (reader) { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const raw = line.slice(6); - if (raw === "[DONE]") continue; - try { handleSSEEvent(JSON.parse(raw)); } catch { } - } - } - } - } catch (err) { - hideThinking(); - addMessage("error", "Network error: " + /** @type {Error} */ (err).message); - } - - agent.isSending = false; - hideThinking(); - clearToolChips(); - } - - /* ════════════════════════════════════════════════════════════ - TOOL CHIPS - ════════════════════════════════════════════════════════════ */ - - function showToolChip(/** @type {string} */ nodeId, /** @type {string} */ toolName, /** @type {string} */ status, /** @type {number|undefined} */ durationMs, /** @type {HTMLElement|null} */ targetEl, /** @type {Map|null} */ targetMap) { - const area = targetEl || chatActivity; - const toolMap = targetMap || agents.get(selectedAgent || "")?.activeTools; - if (!area || !toolMap) return; - - let chip = toolMap.get(nodeId); - if (!chip) { - chip = document.createElement("div"); - chip.className = "tool-chip running"; - chip.innerHTML = `🔧${toolName}`; - area.appendChild(chip); - toolMap.set(nodeId, chip); - } - - if (status === "complete" || status === "completed") { - chip.className = "tool-chip complete"; - const ms = durationMs != null ? ` · ${durationMs}ms` : ""; - chip.innerHTML = `${toolName}${ms}`; - setTimeout(() => { toolMap.delete(nodeId); }, 2000); - } else if (status === "failed") { - chip.className = "tool-chip failed"; - chip.innerHTML = `${toolName}`; - toolMap.delete(nodeId); - } - } - - function clearToolChips() { - if (!selectedAgent) return; - const agent = agents.get(selectedAgent); - if (agent) agent.activeTools.clear(); - setTimeout(() => { chatActivity.innerHTML = ""; }, 2000); - } - - /* ════════════════════════════════════════════════════════════ - DELEGATION TREE - ════════════════════════════════════════════════════════════ */ - - function createDelegationNode(/** @type {string} */ nodeId, /** @type {string} */ fromAgent, /** @type {string} */ toAgent, /** @type {string} */ message, /** @type {HTMLElement} */ parentContainer) { - const node = document.createElement("div"); - node.className = "delegation-node"; - node.dataset.nodeId = nodeId; - - node.innerHTML = ` - - - - -
-
-
- ${toAgent} - from ${fromAgent} -
-
-
-
`; - - parentContainer.appendChild(node); - - const msgsEl = /** @type {HTMLElement} */ ($(".delegation-card-messages", node)); - const activityEl = /** @type {HTMLElement} */ ($(".delegation-card-activity", node)); - - if (message) { - const label = document.createElement("div"); - label.className = "delegation-from-label"; - label.textContent = `↗ Query from ${fromAgent}`; - msgsEl.appendChild(label); - - const msgDiv = document.createElement("div"); - msgDiv.className = "chat-msg user"; - msgDiv.style.fontSize = "12px"; - msgDiv.style.maxWidth = "95%"; - msgDiv.textContent = message; - msgsEl.appendChild(msgDiv); - } - - const thinkingEl = document.createElement("div"); - thinkingEl.className = "chat-thinking"; - thinkingEl.innerHTML = ""; - msgsEl.appendChild(thinkingEl); - - /** @type {DelegationState} */ - const state = { - id: nodeId, from: fromAgent, to: toAgent, - nodeEl: node, messagesEl: msgsEl, activityEl, - response: "", thinkingEl, activeTools: new Map(), children: [], - }; - activeDelegations.set(nodeId, state); - - const targetAgent = agents.get(toAgent); - if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.add("delegating"); - - updateTreePanelVisibility(); - setTimeout(() => { node.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, 100); - return state; - } - - function endDelegationNode(/** @type {string} */ nodeId) { - const del = activeDelegations.get(nodeId); - if (!del) return; - - if (del.thinkingEl) { del.thinkingEl.remove(); del.thinkingEl = null; } - - if (del.response && del.messagesEl) { - const msgDiv = document.createElement("div"); - msgDiv.className = "chat-msg agent"; - msgDiv.style.fontSize = "12px"; - msgDiv.style.maxWidth = "95%"; - msgDiv.innerHTML = marked.parse(del.response); - del.messagesEl.appendChild(msgDiv); - } - - const card = del.nodeEl ? $(".delegation-card", del.nodeEl) : null; - if (card) { card.classList.remove("active"); card.classList.add("completed"); } - - const arrow = del.nodeEl ? $(".delegation-connector-arrow", del.nodeEl) : null; - if (arrow) { - arrow.innerHTML = ` - - `; - } - - const targetAgent = agents.get(del.to); - if (targetAgent?.sidebarEl) targetAgent.sidebarEl.classList.remove("delegating"); - - activeDelegations.delete(nodeId); - updateTreePanelVisibility(); - } - - function getDelegationChildContainer(/** @type {DelegationState} */ parentDel) { - if (!parentDel.nodeEl) return delegationTree; - let nested = /** @type {HTMLElement|null} */ ($(".delegation-nested", parentDel.nodeEl)); - if (!nested) { - nested = document.createElement("div"); - nested.className = "delegation-nested delegation-tree"; - parentDel.nodeEl.appendChild(nested); - } - return nested; - } - - /* ════════════════════════════════════════════════════════════ - SSE EVENT ROUTER - ════════════════════════════════════════════════════════════ */ - - function findInTree(/** @type {any[]} */ tree, /** @type {string} */ nodeId, /** @type {any[]} */ ancestors = []) { - for (const n of tree) { - if (n.id === nodeId) return { node: n, ancestors }; - if (n.children?.length) { - const r = findInTree(n.children, nodeId, [...ancestors, n]); - if (r) return r; - } - } - return null; - } - - function getDelegationForEvent(/** @type {any[]} */ tree, /** @type {string} */ nodeId) { - if (!nodeId || !tree) return null; - const r = findInTree(tree, nodeId); - if (!r) return null; - for (const anc of r.ancestors) { - if (anc.kind === "delegation") { - const del = activeDelegations.get(anc.id); - if (del) return del; - } - } - return null; - } - - function handleSSEEvent(/** @type {any} */ evt) { - const event = evt.event; - switch (event) { - case "turn_complete": - hideThinking(); - if (evt.response) addMessage("agent", evt.response); - for (const [nid] of activeDelegations) endDelegationNode(nid); - break; - - case "turn_error": - hideThinking(); - addMessage("error", evt.error || "Unknown error"); - for (const [nid] of activeDelegations) endDelegationNode(nid); - break; - - case "tool_start": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (del) showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, del.activityEl, del.activeTools); - else showToolChip(evt.node_id, evt.tool || "tool", "running", undefined, null, null); - break; - } - - case "tool_end": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (del) showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, del.activityEl, del.activeTools); - else showToolChip(evt.node_id, evt.tool || "tool", "complete", evt.duration_ms, null, null); - break; - } - - case "delegation_start": { - const fromAgent = evt.from_agent || selectedAgent || ""; - const toAgent = evt.to_agent; - let parentContainer = delegationTree; - for (const del of activeDelegations.values()) { - if (del.to === fromAgent) { parentContainer = getDelegationChildContainer(del); break; } - } - createDelegationNode(evt.node_id, fromAgent, toAgent, evt.message || "", parentContainer); - break; - } - - case "delegation_end": { - const del = activeDelegations.get(evt.node_id); - if (del) del.response = evt.response || ""; - endDelegationNode(evt.node_id); - break; - } - - case "llm_start": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (!del) showThinking(); - break; - } - - case "llm_end": { - const del = getDelegationForEvent(evt.tree, evt.node_id); - if (!del) hideThinking(); - break; - } - - default: break; - } - } - - /* ════════════════════════════════════════════════════════════ - AGENT CRUD MODAL - ════════════════════════════════════════════════════════════ */ - - function openCreateModal() { - editingAgent = null; - agentForm.reset(); - afName.disabled = false; - afSubmit.textContent = "Create"; - afDelete.classList.add("hidden"); - modalTitle.textContent = "New Agent"; - modalOverlay.classList.remove("hidden"); - } - - async function openEditModal(/** @type {string} */ name) { - editingAgent = name; - afName.disabled = true; - afSubmit.textContent = "Save"; - afDelete.classList.remove("hidden"); - modalTitle.textContent = `Edit ${name}`; - modalOverlay.classList.remove("hidden"); - try { - const res = await api(`/api/agents/${name}`); - const data = await res.json(); - afName.value = name; - afDesc.value = data.description || ""; - afPrompt.value = data.system_prompt || ""; - afModel.value = data.model || ""; - afTools.value = (data.tools || []).join(", "); - afSubs.value = (data.sub_agents || []).join(", "); - } catch { agentForm.reset(); afName.value = name; } - } - - function closeModal() { modalOverlay.classList.add("hidden"); } - - async function submitAgent(/** @type {Event} */ e) { - e.preventDefault(); - const payload = { - name: afName.value.trim(), - description: afDesc.value.trim() || "Agent", - system_prompt: afPrompt.value.trim(), - model: afModel.value.trim() || "${default}", - tools: afTools.value ? afTools.value.split(",").map(s => s.trim()).filter(Boolean) : [], - sub_agents: afSubs.value ? afSubs.value.split(",").map(s => s.trim()).filter(Boolean) : [], - }; - try { - if (editingAgent) await api(`/api/agents/${editingAgent}`, { method: "PUT", body: JSON.stringify(payload) }); - else await api("/api/agents", { method: "POST", body: JSON.stringify(payload) }); - closeModal(); - await reloadAgents(); - } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } - } - - async function deleteCurrentAgent() { - if (!editingAgent || !confirm(`Delete agent "${editingAgent}"?`)) return; - try { - await api(`/api/agents/${editingAgent}`, { method: "DELETE" }); - closeModal(); - const agent = agents.get(editingAgent); - if (agent?.sidebarEl) agent.sidebarEl.remove(); - agents.delete(editingAgent); - if (editingAgent === selectedAgent) { - selectedAgent = null; - if (agents.size > 0) selectAgent(/** @type {string} */(agents.keys().next().value)); - else { emptyState.classList.remove("hidden"); primaryChat.classList.add("hidden"); sidebar.classList.remove("collapsed"); } - } - } catch (err) { alert("Failed: " + /** @type {Error} */ (err).message); } - } - - async function reloadAgents() { - for (const a of agents.values()) { if (a.sidebarEl) a.sidebarEl.remove(); } - agents.clear(); - selectedAgent = null; - sidebar.classList.remove("collapsed"); - emptyState.classList.remove("hidden"); - primaryChat.classList.add("hidden"); - activeDelegations.clear(); - delegationTree.innerHTML = ""; - chatMessagesInner.innerHTML = ""; - chatActivity.innerHTML = ""; - updateTreePanelVisibility(); - await loadAgents(); - } - - /* ════════════════════════════════════════════════════════════ - EVENT BINDINGS (tracked for cleanup) - ════════════════════════════════════════════════════════════ */ - /** @type {Array<[EventTarget, string, EventListener]>} */ - const listeners = []; - - function on(/** @type {EventTarget} */ el, /** @type {string} */ evt, /** @type {EventListener} */ fn) { - el.addEventListener(evt, fn); - listeners.push([el, evt, fn]); - } - - on(chatInput, "keydown", (e) => { - if (/** @type {KeyboardEvent} */ (e).key === "Enter" && !/** @type {KeyboardEvent} */ (e).shiftKey) { - e.preventDefault(); sendMessage(); - } - }); - on(chatSendBtn, "click", () => sendMessage()); - on(ref("btn-edit-agent"), "click", () => { if (selectedAgent) openEditModal(selectedAgent); }); - on(ref("btn-add-agent"), "click", openCreateModal); - on(ref("btn-refresh-agents"), "click", async () => { - const btn = ref("btn-refresh-agents"); - btn.classList.add("spinning"); - try { await reloadAgents(); } finally { setTimeout(() => btn.classList.remove("spinning"), 400); } - }); - on(sidebar, "click", (e) => { - if (!sidebar.classList.contains("collapsed")) return; - if (/** @type {HTMLElement} */ (e.target).closest(".sidebar-agent")) return; - sidebar.classList.toggle("expanded"); - }); - on(document, "click", (e) => { - if (!sidebar.classList.contains("expanded")) return; - if (!sidebar.contains(/** @type {Node} */(e.target))) { - sidebar.classList.remove("expanded"); - } - }); - on(ref("modal-close"), "click", closeModal); - on(modalOverlay, "click", (e) => { if (e.target === modalOverlay) closeModal(); }); - on(afCancel, "click", closeModal); - on(agentForm, "submit", submitAgent); - on(afDelete, "click", deleteCurrentAgent); - - /* ════════════════════════════════════════════════════════════ - INIT - ════════════════════════════════════════════════════════════ */ - loadAgents(); - - /* ════════════════════════════════════════════════════════════ - DESTROY — cleanup for unmount - ════════════════════════════════════════════════════════════ */ - return { - destroy() { - destroyed = true; - for (const [el, evt, fn] of listeners) el.removeEventListener(evt, fn); - listeners.length = 0; - rootEl.classList.remove("root"); - rootEl.innerHTML = ""; - }, - }; -} diff --git a/src/opensensa/web/static/styles.css.bak b/src/opensensa/web/static/styles.css.bak deleted file mode 100644 index 996681f..0000000 --- a/src/opensensa/web/static/styles.css.bak +++ /dev/null @@ -1,1354 +0,0 @@ -/* =========================================================================== - * Copyright (C) 2025 CapsicoHealth Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * =========================================================================== */ - -/* ── OpenSensa · Embeddable ES6 Module Styles ──────────────── */ -/* All selectors scoped under .root — no global resets */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); - -/* ── Design Tokens (scoped to .root) ─────────────── */ -.root { - /* Palette */ - --bg: #FAF8F5; - --bg-warm: #F4F0EB; - --surface: #FFFFFF; - --surface-alt: #F7F4F0; - --surface-hover: #F0ECE6; - --border: #E8E2D9; - --border-light: #F0EBE4; - --border-focus: #D4A574; - - --accent: #E8751A; - --accent-dark: #D06515; - --accent-light: #F5C9A0; - --accent-muted: #F9E0C5; - --accent-bg: #FFF7F0; - --accent-glow: rgba(232, 117, 26, .18); - - --text: #37352F; - --text-mid: #6B6760; - --text-muted: #A09B93; - --text-faint: #C4BFB7; - - --success: #3B8C5F; - --success-bg: #EDF7F0; - --warning: #C48B2C; - --error: #C4554D; - --error-bg: #FDF2F2; - --info: #5B8DC9; - - /* Layout */ - --toolbar-h: 50px; - --sidebar-w: 20%; - --radius: 16px; - --radius-sm: 12px; - --radius-xs: 8px; - --radius-pill: 100px; - - /* Type */ - --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - --mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(55, 53, 47, .04), 0 0 0 1px rgba(55, 53, 47, .04); - --shadow-card: 0 1px 4px rgba(55, 53, 47, .06), 0 0 0 1px rgba(55, 53, 47, .05); - --shadow-card-hover: 0 4px 16px rgba(55, 53, 47, .08), 0 0 0 1px rgba(55, 53, 47, .06); - --shadow-card-active: 0 6px 24px rgba(55, 53, 47, .10), 0 0 0 2px var(--accent); - --shadow-lg: 0 12px 40px rgba(55, 53, 47, .12); - - /* Root container styles */ - font-family: var(--font); - font-size: 14px; - color: var(--text); - background: var(--bg); - width: 100%; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.root *, -.root *::before, -.root *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* ── Toolbar ─────────────────────────────────────────────── */ -.toolbar { - height: var(--toolbar-h); - background: var(--surface); - border-bottom: 1px solid var(--border-light); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20px; - gap: 10px; - position: relative; - z-index: 200; - flex-shrink: 0; -} - -.toolbar-section { - display: flex; - align-items: center; - gap: 6px; -} - -.toolbar-right { - margin-left: auto; -} - -.logo { - font-size: 16px; - font-weight: 700; - color: var(--text); - display: flex; - align-items: center; - gap: 8px; - letter-spacing: -.3px; - user-select: none; -} - -.logo svg { - color: var(--accent); -} - -.tool-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - height: 32px; - min-width: 32px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - background: var(--surface); - color: var(--text-mid); - font-family: var(--font); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all .15s ease; -} - -.tool-btn:hover { - background: var(--surface-hover); - color: var(--text); -} - -.tool-btn-accent { - background: var(--text); - border-color: var(--text); - color: var(--surface); - font-weight: 600; -} - -.tool-btn-accent:hover { - background: #2C2B27; - border-color: #2C2B27; - color: var(--surface); -} - -/* ══════════════════════════════════════════════════════════ - App Layout: Sidebar + Chat + Tree (3-column) - ══════════════════════════════════════════════════════════ */ -.app-layout { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; - position: relative; -} - -/* ── Sidebar ─────────────────────────────────────────────── */ -.sidebar { - flex: 0 0 var(--sidebar-w); - width: var(--sidebar-w); - background: var(--surface); - border-right: 1px solid var(--border-light); - display: flex; - flex-direction: column; - overflow: hidden; - transition: flex-basis .3s cubic-bezier(.4, 0, .2, 1), width .3s cubic-bezier(.4, 0, .2, 1), box-shadow .3s ease; -} - -.sidebar-header { - padding: 16px 18px 10px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: space-between; -} - -.sidebar-refresh-btn { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 4px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: color .2s, background .2s, transform .4s ease; -} - -.sidebar-refresh-btn:hover { - color: var(--accent); - background: var(--bg-hover); -} - -.sidebar-refresh-btn.spinning svg { - animation: spin-once .5s ease-in-out; -} - -@keyframes spin-once { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.sidebar-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .8px; - color: var(--text-muted); -} - -.sidebar-list { - flex: 1; - overflow-y: auto; - padding: 0 8px 12px; -} - -.sidebar-list::-webkit-scrollbar { - width: 4px; -} - -.sidebar-list::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -/* ── Sidebar Collapsed State ─────────────────────────────── */ -.sidebar.collapsed { - flex: 0 0 48px; - width: 48px; - position: relative; - z-index: 50; -} - -.sidebar.collapsed .sidebar-header { - padding: 14px 0 8px; - display: flex; - justify-content: center; -} - -.sidebar.collapsed .sidebar-refresh-btn { - display: none; -} - -.sidebar.collapsed .sidebar-title { - font-size: 0; - width: 20px; - height: 20px; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - opacity: 1; -} - -.sidebar.collapsed .sidebar-title::after { - content: ''; - display: block; - width: 16px; - height: 2px; - background: var(--text-muted); - border-radius: 2px; - box-shadow: 0 5px 0 var(--text-muted), 0 10px 0 var(--text-muted); -} - -.sidebar.collapsed .sidebar-list { - display: none; -} - -/* Click-expanded overlay — stays in flex flow so chat adjusts */ -.sidebar.collapsed.expanded { - flex: 0 0 var(--sidebar-w); - width: var(--sidebar-w); - box-shadow: var(--shadow-lg); - z-index: 100; -} - -.sidebar.collapsed.expanded .sidebar-header { - padding: 16px 18px 10px; - justify-content: space-between; -} - -.sidebar.collapsed.expanded .sidebar-title { - font-size: 11px; - width: auto; - height: auto; -} - -.sidebar.collapsed.expanded .sidebar-title::after { - display: none; -} - -.sidebar.collapsed.expanded .sidebar-refresh-btn { - display: flex; -} - -.sidebar.collapsed.expanded .sidebar-list { - display: block; - padding: 0 8px 12px; -} - -.sidebar.collapsed .sidebar-header { - cursor: pointer; -} - -/* main-area is already flex:1, no override needed when sidebar collapses */ - -/* ── Sidebar Agent Entry ─────────────────────────────────── */ -.sidebar-agent { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - margin-bottom: 2px; - border-radius: var(--radius-xs); - cursor: pointer; - transition: background .2s ease, box-shadow .2s ease, transform .15s ease; - position: relative; - user-select: none; -} - -.sidebar-agent:active { - transform: scale(.98); -} - -.sidebar-agent:hover { - background: var(--surface-hover); -} - -.sidebar-agent.active { - background: var(--accent-bg); - box-shadow: inset 3px 0 0 var(--accent); - animation: sidebarSelect .3s ease; -} - -@keyframes sidebarSelect { - from { - background: var(--surface-hover); - transform: translateX(0); - } - - 40% { - transform: translateX(4px); - } - - to { - background: var(--accent-bg); - transform: translateX(0); - } -} - -.sidebar-agent.delegating { - background: var(--success-bg); -} - -.sidebar-agent-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--text-faint); - flex-shrink: 0; - margin-top: 5px; - transition: background .3s; -} - -.sidebar-agent.active .sidebar-agent-dot { - background: var(--accent); -} - -.sidebar-agent.delegating .sidebar-agent-dot { - background: var(--success); -} - -.sidebar-agent-info { - flex: 1; - min-width: 0; -} - -.sidebar-agent-name { - font-size: 13px; - font-weight: 600; - color: var(--text); - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -.15px; -} - -.sidebar-agent-desc { - font-size: 11px; - color: var(--text-muted); - display: block; - margin-top: 2px; - line-height: 1.4; -} - -.sidebar-agent-edit { - width: 24px; - height: 24px; - border: none; - background: none; - color: var(--text-faint); - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: all .12s; - flex-shrink: 0; -} - -.sidebar-agent:hover .sidebar-agent-edit { - opacity: 1; -} - -.sidebar-agent-edit:hover { - background: var(--surface-alt); - color: var(--text-mid); -} - -/* ══════════════════════════════════════════════════════════ - Centre: Chat Area - ══════════════════════════════════════════════════════════ */ -.main-area { - flex: 1 1 0; - min-width: 0; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--bg); - position: relative; - padding: 20px 24px; - transition: flex .3s cubic-bezier(.4, 0, .2, 1); -} - -/* Empty state */ -.empty-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - color: var(--text-muted); -} - -.empty-state svg { - opacity: .2; - stroke: var(--text-faint); -} - -.empty-state p { - font-size: 15px; - font-weight: 600; - color: var(--text-mid); -} - -.empty-state span { - font-size: 13px; -} - -/* ── Primary Chat Panel ──────────────────────────────────── */ -.chat-panel { - background: var(--surface); - border-radius: var(--radius); - box-shadow: var(--shadow-card); - border: 1.5px solid var(--border-light); - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - animation: panelSlideIn .25s ease; -} - -@keyframes panelSlideIn { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.chat-panel.panel-enter { - animation: panelEnter .3s cubic-bezier(.4, 0, .2, 1); -} - -@keyframes panelEnter { - 0% { - opacity: 0; - transform: translateX(-16px) scale(.98); - } - - 100% { - opacity: 1; - transform: translateX(0) scale(1); - } -} - -.chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 20px; - border-bottom: 1px solid var(--border-light); - flex-shrink: 0; -} - -.chat-header-left { - display: flex; - align-items: center; - gap: 10px; -} - -.chat-header-right { - display: flex; - align-items: center; - gap: 4px; -} - -.chat-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - flex-shrink: 0; -} - -.chat-status-dot.thinking { - animation: dotBreathe 1.2s ease-in-out infinite; -} - -.chat-agent-name { - font-size: 15px; - font-weight: 700; - color: var(--text); - letter-spacing: -.2px; -} - -.chat-agent-model { - font-size: 10.5px; - font-weight: 600; - font-family: var(--mono); - color: var(--text-muted); - background: var(--surface-alt); - padding: 2px 7px; - border-radius: 4px; - letter-spacing: .3px; -} - -.icon-btn { - width: 30px; - height: 30px; - border: none; - background: none; - color: var(--text-muted); - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .12s; -} - -.icon-btn:hover { - background: var(--surface-alt); - color: var(--text); -} - -/* ── Chat Messages ───────────────────────────────────────── */ -.chat-messages { - flex: 1; - overflow-y: auto; - min-height: 0; -} - -.chat-messages::-webkit-scrollbar { - width: 4px; -} - -.chat-messages::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -.chat-messages-inner { - padding: 16px 20px; - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Messages */ -.chat-msg { - font-size: 13.5px; - line-height: 1.6; - max-width: 80%; - padding: 10px 14px; - border-radius: var(--radius-sm); - word-break: break-word; - animation: msgUp .2s ease; -} - -@keyframes msgUp { - from { - opacity: 0; - transform: translateY(4px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.chat-msg.user { - align-self: flex-end; - background: var(--text); - color: var(--surface); - border-bottom-right-radius: 4px; -} - -.chat-msg.agent { - align-self: flex-start; - background: var(--surface-alt); - color: var(--text); - border-bottom-left-radius: 4px; - border: 1px solid var(--border-light); -} - -.chat-msg.agent p { - margin: 0 0 .3em; -} - -.chat-msg.agent p:last-child { - margin: 0; -} - -.chat-msg.agent pre { - background: #2C2B27; - color: #E8E2D9; - padding: 10px 12px; - border-radius: 8px; - margin: 6px 0; - overflow-x: auto; - font-size: 12px; - font-family: var(--mono); -} - -.chat-msg.agent code { - font-family: var(--mono); - font-size: .88em; -} - -.chat-msg.agent :not(pre)>code { - background: rgba(55, 53, 47, .06); - padding: 1px 5px; - border-radius: 4px; -} - -.chat-msg.user :not(pre)>code { - background: rgba(255, 255, 255, .15); -} - -.chat-msg.error-msg { - align-self: center; - background: var(--error-bg); - color: var(--error); - font-size: 12.5px; - text-align: center; - border-radius: var(--radius-pill); - padding: 5px 16px; -} - -.chat-msg.system-msg { - align-self: center; - background: none; - color: var(--text-muted); - font-size: 11.5px; - text-align: center; - padding: 2px 8px; -} - -/* Thinking dots */ -.chat-thinking { - display: flex; - gap: 4px; - padding: 8px 12px; - align-self: flex-start; -} - -.chat-thinking span { - width: 6px; - height: 6px; - background: var(--accent-light); - border-radius: 50%; - animation: dots .7s ease-in-out infinite; -} - -.chat-thinking span:nth-child(2) { - animation-delay: .12s; -} - -.chat-thinking span:nth-child(3) { - animation-delay: .24s; -} - -@keyframes dots { - - 0%, - 80%, - 100% { - transform: scale(.5); - opacity: .3; - } - - 40% { - transform: scale(1); - opacity: 1; - } -} - -/* ── Chat Activity (Tool Chips) ──────────────────────────── */ -.chat-activity { - min-height: 0; - overflow: hidden; - padding: 0 20px; - transition: padding .2s, min-height .2s; -} - -.chat-activity:not(:empty) { - padding: 6px 20px; - min-height: 32px; -} - -.tool-chip { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 500; - padding: 3px 8px; - border-radius: 6px; - margin: 2px 2px; - animation: chipPop .15s ease; -} - -@keyframes chipPop { - from { - opacity: 0; - transform: scale(.95); - } - - to { - opacity: 1; - transform: scale(1); - } -} - -.tool-chip.running { - background: var(--accent-bg); - color: var(--accent); - border: 1px solid var(--accent-muted); -} - -.tool-chip.complete { - background: var(--success-bg); - color: var(--success); - border: 1px solid #C6E7D0; -} - -.tool-chip.failed { - background: var(--error-bg); - color: var(--error); - border: 1px solid #F5D0CD; -} - -.tool-chip-icon { - font-size: 11px; -} - -.spinner { - width: 9px; - height: 9px; - border: 1.5px solid var(--accent-muted); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin .65s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* ── Context Headers Bar (above chat input) ──────────────── */ -.context-headers-bar { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 8px 16px; - border-top: 1px solid var(--border-light); - background: var(--bg-body); - flex-shrink: 0; -} - -.context-headers-bar.hidden { - display: none; -} - -.ctx-header-field { - display: flex; - align-items: center; - gap: 6px; - flex: 1 1 200px; - min-width: 180px; -} - -.ctx-header-label { - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; -} - -.ctx-header-input { - flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - padding: 5px 10px; - font-family: var(--font); - font-size: 12.5px; - color: var(--text); - background: var(--surface); - outline: none; - transition: border-color .15s; -} - -.ctx-header-input:focus { - border-color: var(--accent); -} - -.ctx-header-input::placeholder { - color: var(--text-faint); - font-size: 11px; -} - -/* ── Chat Input ──────────────────────────────────────────── */ -.chat-input-area { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - border-top: 1px solid var(--border-light); - flex-shrink: 0; -} - -.chat-input { - flex: 1; - border: 1px solid var(--border); - border-radius: var(--radius-xs); - padding: 10px 14px; - font-family: var(--font); - font-size: 13.5px; - color: var(--text); - background: var(--surface); - outline: none; - transition: border-color .15s, box-shadow .15s; -} - -.chat-input::placeholder { - color: var(--text-faint); - font-size: 13px; -} - -.chat-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} - -.chat-send-btn { - width: 36px; - height: 36px; - border: none; - border-radius: var(--radius-xs); - background: var(--text); - color: var(--surface); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .15s; - flex-shrink: 0; -} - -.chat-send-btn svg { - width: 15px; - height: 15px; -} - -.chat-send-btn:hover { - background: #2C2B27; -} - -.chat-send-btn:disabled { - opacity: .25; - cursor: not-allowed; -} - -/* ══════════════════════════════════════════════════════════ - Link Arrow (between chat and tree) - ══════════════════════════════════════════════════════════ */ -.tree-link-arrow { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 28px; - opacity: 0; - transition: opacity .3s ease; -} - -.tree-link-arrow.visible { - opacity: 1; -} - -/* ══════════════════════════════════════════════════════════ - Right: Delegation Tree Panel - ══════════════════════════════════════════════════════════ */ -.tree-panel { - flex: 0 0 35%; - min-width: 0; - background: var(--surface); - border-left: 1px solid var(--border-light); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.tree-panel-header { - padding: 16px 18px 10px; - flex-shrink: 0; - border-bottom: 1px solid var(--border-light); -} - -.tree-panel-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .8px; - color: var(--text-muted); -} - -.tree-panel-hint { - display: block; - font-size: 11px; - color: var(--text-faint); - margin-top: 4px; - line-height: 1.4; -} - -.tree-panel .delegation-tree { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 12px 10px 20px; -} - -.tree-panel .delegation-tree::-webkit-scrollbar { - width: 4px; -} - -.tree-panel .delegation-tree::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -/* ── Delegation Tree Nodes ───────────────────────────────── */ -.delegation-tree { - display: flex; - flex-direction: column; - align-items: stretch; - position: relative; -} - -.delegation-node { - position: relative; - margin-top: 0; - padding-left: 24px; - animation: delegationSlideIn .35s cubic-bezier(.4, 0, .2, 1); -} - -@keyframes delegationSlideIn { - from { - opacity: 0; - transform: translateY(-12px) scale(.97); - } - - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* Vertical connector */ -.delegation-node::before { - content: ''; - position: absolute; - left: 12px; - top: -16px; - width: 2px; - height: 32px; - background: var(--accent-light); -} - -/* Horizontal connector */ -.delegation-node::after { - content: ''; - position: absolute; - left: 12px; - top: 16px; - width: 16px; - height: 2px; - background: var(--accent-light); -} - -.delegation-connector-arrow { - position: absolute; - left: 6px; - top: 10px; - width: 14px; - height: 14px; - color: var(--accent); - z-index: 1; -} - -/* ── Delegation mini card ────────────────────────────────── */ -.delegation-card { - background: var(--surface); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-card); - border: 1.5px solid var(--accent-muted); - width: 100%; - overflow: hidden; - transition: border-color .3s, box-shadow .3s; -} - -.delegation-card.active { - border-color: var(--accent); - box-shadow: var(--shadow-card), 0 0 0 3px var(--accent-glow); -} - -.delegation-card.completed { - border-color: var(--border-light); - opacity: .85; -} - -.delegation-card-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-bottom: 1px solid var(--border-light); - background: var(--accent-bg); -} - -.delegation-card-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - flex-shrink: 0; -} - -.delegation-card.active .delegation-card-dot { - animation: dotBreathe 1.2s ease-in-out infinite; -} - -.delegation-card.completed .delegation-card-dot { - background: var(--success); -} - -.delegation-card-name { - font-size: 12.5px; - font-weight: 700; - color: var(--text); - flex: 1; - letter-spacing: -.15px; -} - -.delegation-card-from { - font-size: 10px; - color: var(--accent-dark); - font-weight: 500; -} - -.delegation-card-messages { - padding: 10px 14px; - display: flex; - flex-direction: column; - gap: 5px; -} - -.delegation-card-messages::-webkit-scrollbar { - width: 3px; -} - -.delegation-card-messages::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 3px; -} - -.delegation-card-activity { - padding: 0 14px; -} - -.delegation-card-activity:not(:empty) { - padding: 4px 14px 8px; -} - -.delegation-node .delegation-node { - margin-top: 0; -} - -/* ══════════════════════════════════════════════════════════ - Modal - ══════════════════════════════════════════════════════════ */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(55, 53, 47, .25); - backdrop-filter: blur(3px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - animation: fadeIn .12s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.modal { - background: var(--surface); - border-radius: var(--radius); - width: 460px; - max-height: 85vh; - overflow-y: auto; - box-shadow: var(--shadow-lg); - border: 1px solid var(--border-light); - animation: modalSlide .2s ease; -} - -@keyframes modalSlide { - from { - opacity: 0; - transform: translateY(8px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 22px; - border-bottom: 1px solid var(--border-light); -} - -.modal-header h2 { - font-size: 15px; - font-weight: 700; - letter-spacing: -.2px; -} - -.modal-close { - width: 30px; - height: 30px; - border: none; - background: var(--surface-alt); - border-radius: 6px; - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all .12s; -} - -.modal-close:hover { - background: var(--error-bg); - color: var(--error); -} - -.modal-body { - padding: 18px 22px; -} - -.form-group { - margin-bottom: 14px; -} - -.form-group label { - display: block; - font-size: 12.5px; - font-weight: 600; - color: var(--text); - margin-bottom: 5px; - letter-spacing: .2px; -} - -.form-hint { - font-weight: 400; - color: var(--text-muted); -} - -.form-group input, -.form-group textarea { - width: 100%; - padding: 9px 12px; - border: 1.5px solid var(--border); - border-radius: var(--radius-xs); - font-family: var(--font); - font-size: 13.5px; - color: var(--text); - background: var(--surface); - transition: border-color .15s, box-shadow .15s; -} - -.form-group input:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-glow); -} - -.form-group textarea { - min-height: 80px; - resize: vertical; - line-height: 1.5; -} - -.form-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - padding-top: 6px; -} - -.btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 9px 18px; - border: none; - border-radius: var(--radius-xs); - font-family: var(--font); - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all .15s; -} - -.btn-primary { - background: var(--text); - color: var(--surface); -} - -.btn-primary:hover { - background: #2C2B27; -} - -.btn-ghost { - background: none; - color: var(--text-muted); -} - -.btn-ghost:hover { - background: var(--surface-alt); - color: var(--text); -} - -.btn-danger { - background: var(--error); - color: #fff; -} - -.btn-danger:hover { - background: #B04840; -} - -/* ── Utilities ───────────────────────────────────────────── */ -.hidden { - display: none !important; -} - -.delegation-from-label { - font-size: 10.5px; - color: var(--accent); - font-weight: 600; - padding: 2px 0; - display: flex; - align-items: center; - gap: 4px; -} \ No newline at end of file diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index f5cc416..ad7e509 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -61,7 +61,7 @@ def test_parse_agent_definition(): assert defn.skills[0].tags == ["test"] assert defn.input_modes == ["text/plain"] assert defn.output_modes == ["text/plain", "application/json"] - assert defn.context_headers == {"X-Tenant-Id": "acme"} + assert defn.context_headers == ["X-Tenant-Id"] assert "You are a test agent" in defn.system_prompt assert "---" not in defn.system_prompt # Frontmatter should not leak diff --git a/tests/test_config.py b/tests/test_config.py index a098c23..a39d9d6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,14 +22,14 @@ import pytest -from opensensa.config import OpenSensaConfig, load_config, resolve_model +from opensensa.config import AppConfig, load_config, resolve_model def test_default_config(): """Loading with no file should return valid defaults.""" with tempfile.TemporaryDirectory() as tmpdir: config = load_config(project_dir=tmpdir) - assert isinstance(config, OpenSensaConfig) + assert isinstance(config, AppConfig) assert config.server.mcp_port == 8001 assert config.server.orchestrator_port == 8000 @@ -80,7 +80,7 @@ def test_env_var_interpolation(): def test_resolve_model(): """resolve_model should look up models by name and handle 'default'.""" - config = OpenSensaConfig.model_validate({ + config = AppConfig.model_validate({ "models": { "default": "my-model", "registry": { diff --git a/tests/test_framework_tools.py b/tests/test_framework_tools.py index 35cbbfa..fbbaf8a 100644 --- a/tests/test_framework_tools.py +++ b/tests/test_framework_tools.py @@ -357,6 +357,7 @@ class TestDelegate: def _make_agent_def(self, sub_agents=None): """Create a minimal AgentDefinition-like object for testing.""" + from pathlib import Path from opensensa.orchestrator.agent_registry import AgentDefinition return AgentDefinition( name="caller-agent", @@ -365,7 +366,11 @@ def _make_agent_def(self, sub_agents=None): model="test-model", tools=[], sub_agents=sub_agents or [], - source_file=None, + skills=[], + input_modes=["text/plain"], + output_modes=["text/plain"], + context_headers=[], + source_path=Path("/dev/null"), ) @pytest.mark.asyncio From f841d5f89f0ec23651494e5743fbad9428c75d53 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Mon, 20 Apr 2026 09:44:56 -0400 Subject: [PATCH 12/13] agent builder changes --- src/opensensa/orchestrator/agent_builder.py | 55 ++++++++++++--------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/opensensa/orchestrator/agent_builder.py b/src/opensensa/orchestrator/agent_builder.py index 132f2ce..723ea11 100644 --- a/src/opensensa/orchestrator/agent_builder.py +++ b/src/opensensa/orchestrator/agent_builder.py @@ -78,13 +78,32 @@ async def build_agent( # MCP is only needed for tools — delegation uses native function tools mcp_servers = [] + # Build tool filter if agent specifies specific tools (must be done before + # creating the MCP server so we can pass it to MCPServerStreamableHttp directly). + tool_filter = None + if agent_def.tools: + allowed = set(agent_def.tools) + try: + from agents.mcp import create_static_tool_filter + tool_filter = create_static_tool_filter(list(allowed)) + except ImportError: + def tool_filter(tool) -> bool: # type: ignore[misc] + name = getattr(tool, "name", None) or getattr(tool, "function", {}).get("name", "") + return name in allowed + if agent_def.tools: mcp_server = MCPServerStreamableHttp( params={ "url": mcp_server_url, - "timeout": 30, # tool calls like document_search can take >5s + "timeout": 300, # tool calls like document_search can take >5s **({"headers": headers} if headers else {}), }, + client_session_timeout_seconds=300, + # Unique name prevents the SDK from sharing cached tool lists + # across differently-filtered connections within the same process. + name=f"MCP-{agent_def.name}", + # Pass filter directly to the server — this is where the SDK enforces it. + **({"tool_filter": tool_filter}) if tool_filter else {}, ) mcp_servers.append(mcp_server) @@ -98,23 +117,6 @@ async def build_agent( "Verify the URL is correct and the server is running." ) from exc - # Build tool filter if agent specifies specific tools - tool_filter = None - if agent_def.tools: - allowed = set(agent_def.tools) - - def _tool_filter(tool) -> bool: - """Only allow tools listed in the agent definition.""" - name = getattr(tool, "name", None) or getattr(tool, "function", {}).get("name", "") - return name in allowed - - # Use static tool filter from agents SDK if available - try: - from agents.mcp import create_static_tool_filter - tool_filter = create_static_tool_filter(list(allowed)) - except ImportError: - tool_filter = _tool_filter - # Build native function tools (non-MCP) native_tools: list[Any] = [] @@ -137,16 +139,25 @@ def _tool_filter(tool) -> bool: ) native_tools.append(delegate_tool) + # Substitute context_headers values into the system prompt so that + # placeholders like {cohort_refnum} are resolved before the LLM sees them. + # The header name is lowercased and the "X-" prefix is stripped to derive + # the placeholder key: "X-Cohort-Refnum" → "cohort_refnum". + resolved_prompt = agent_def.system_prompt + substitutions = context_headers or {} + if mcp_headers: + substitutions = {**mcp_headers, **substitutions} + for header_key, header_value in substitutions.items(): + placeholder = header_key.lstrip("X-").lstrip("x-").replace("-", "_").lower() + resolved_prompt = resolved_prompt.replace("{" + placeholder + "}", str(header_value)) + # Build agent agent = Agent( name=agent_def.name, - instructions=agent_def.system_prompt, + instructions=resolved_prompt, model=model, tools=native_tools, mcp_servers=mcp_servers, - mcp_config={ - "tool_filter": tool_filter, - } if tool_filter else {}, ) logger.info(f"Built agent: {agent_def.name} (model={agent_def.model}, tools={agent_def.tools}, sub_agents={agent_def.sub_agents})") From 835a0364d75f3244f2ed40b768541c45a434b813 Mon Sep 17 00:00:00 2001 From: shloke-capsico Date: Tue, 9 Jun 2026 07:56:39 -0400 Subject: [PATCH 13/13] #12 delegation bug fixes --- src/opensensa/framework_tools/delegate.py | 134 ++++++++++++++++++++ src/opensensa/orchestrator/agent_builder.py | 2 + 2 files changed, 136 insertions(+) diff --git a/src/opensensa/framework_tools/delegate.py b/src/opensensa/framework_tools/delegate.py index 5482c2d..6da167c 100644 --- a/src/opensensa/framework_tools/delegate.py +++ b/src/opensensa/framework_tools/delegate.py @@ -269,6 +269,97 @@ async def _try_stream_delegation( return tools_used, {"artifacts": artifacts} +async def _run_ephemeral_in_process( + agent_name: str, + message: str, + *, + agent_registry, + app_config, + mcp_server_url: str, + context_headers: dict[str, str] | None, + remote_agents: list[dict] | None, + call_graph, + client_request_id: str | None, + current_depth: int, +) -> str: + """Run an ephemeral agent in-process instead of via an HTTP call. + + Ephemeral agents have no mounted sub-app, so delegating via HTTP would yield + a 404. Instead we build the agent directly with ``build_agent()`` and run it + in the same process, reusing the same EphemeralRegistry so that nested + ephemeral sub-agents of ephemeral sub-agents also take this path. + """ + import time as _time + from contextlib import AsyncExitStack + from agents import Runner + from opensensa.orchestrator.agent_builder import build_agent + + _start = _time.time() + + agent_def = agent_registry.get(agent_name) + if agent_def is None: + return json.dumps({ + "status": "error", + "error": f"Ephemeral agent '{agent_name}' not found in registry.", + }) + + mcp_headers: dict[str, str] = { + "X-Agent-Id": agent_name, + "X-Agent-Depth": str(current_depth), + } + if context_headers: + mcp_headers.update(context_headers) + + try: + async with AsyncExitStack() as stack: + agent = await build_agent( + agent_def=agent_def, + config=app_config, + mcp_server_url=mcp_server_url, + exit_stack=stack, + mcp_headers=mcp_headers, + agent_registry=agent_registry, + remote_agents=remote_agents, + call_graph=call_graph, + client_request_id=client_request_id, + context_headers=context_headers, + current_depth=current_depth, + ) + result = await Runner.run( + agent, + [{"role": "user", "content": message}], + ) + final_text = str(result.final_output) if result.final_output else "" + duration_ms = int((_time.time() - _start) * 1000) + task_result = { + "artifacts": [ + { + "name": f"{agent_name}-response", + "parts": [{"text": final_text}], + } + ] + } + # Structured telemetry log for ephemeral in-process delegations. + # No HTTP handler exists for these, so no automatic agenticaccesslog row. + logger.info( + "EPHEMERAL_DELEGATION_COMPLETE agent=%s depth=%d duration_ms=%d " + "status=success response_len=%d", + agent_name, current_depth, duration_ms, len(final_text), + ) + return json.dumps({"status": "success", "agent": agent_name, "result": task_result}) + except Exception as exc: + duration_ms = int((_time.time() - _start) * 1000) + logger.error( + "EPHEMERAL_DELEGATION_COMPLETE agent=%s depth=%d duration_ms=%d " + "status=error error=%s", + agent_name, current_depth, duration_ms, exc, + ) + return json.dumps({ + "status": "error", + "error": f"In-process execution of '{agent_name}' failed: {exc}", + }) + + async def _delegate_impl( message: str, *, @@ -283,6 +374,8 @@ async def _delegate_impl( call_graph=None, client_request_id: str | None = None, context_headers: dict[str, str] | None = None, + app_config=None, + mcp_server_url: str | None = None, ) -> str: """Core delegation logic — sends an A2A request to another agent. @@ -348,6 +441,43 @@ async def _delegate_impl( }) resolved_url = resolved_url_or_none + # In-process shortcut: ephemeral agents have no mounted sub-app, so an + # HTTP call to /agents/{name}/ would return 404. Run the agent directly + # in-process using the same EphemeralRegistry so that nested ephemeral + # sub-agents of ephemeral sub-agents also take this path recursively. + if ( + agent_name + and agent_url is None + and app_config is not None + and mcp_server_url is not None + and hasattr(agent_registry, "is_ephemeral") + and agent_registry.is_ephemeral(agent_name) + ): + logger.info("Delegating to ephemeral agent '%s' in-process (no HTTP)", agent_name) + if call_graph is not None: + call_graph.update_delegation_target(agent_name) + await _notify_delegation_start(call_graph, from_agent_name, agent_name, message=message) + result_json = await _run_ephemeral_in_process( + agent_name, + message, + agent_registry=agent_registry, + app_config=app_config, + mcp_server_url=mcp_server_url, + context_headers=context_headers, + remote_agents=remote_agents, + call_graph=call_graph, + client_request_id=client_request_id, + current_depth=current_depth + 1, + ) + result_data = json.loads(result_json) + delegate_response = ( + _extract_response_text(result_data.get("result", {})) + if result_data.get("status") == "success" + else "" + ) + await _notify_delegation_end(call_graph, agent_name, response=delegate_response) + return result_json + # Build A2A request parameters a2a_endpoint = f"{resolved_url}/" @@ -431,6 +561,8 @@ def build_delegate_tool( client_request_id: str | None = None, context_headers: dict[str, str] | None = None, current_depth: int = 0, + app_config=None, + mcp_server_url: str | None = None, ): """Build a native ``FunctionTool`` for delegating to other agents. @@ -476,6 +608,8 @@ async def _on_invoke(ctx, args_json: str) -> str: call_graph=call_graph, client_request_id=client_request_id, context_headers=context_headers, + app_config=app_config, + mcp_server_url=mcp_server_url, ) # Build a description that reflects available modes diff --git a/src/opensensa/orchestrator/agent_builder.py b/src/opensensa/orchestrator/agent_builder.py index 723ea11..063f4b4 100644 --- a/src/opensensa/orchestrator/agent_builder.py +++ b/src/opensensa/orchestrator/agent_builder.py @@ -136,6 +136,8 @@ def tool_filter(tool) -> bool: # type: ignore[misc] client_request_id=client_request_id, context_headers=context_headers, current_depth=current_depth, + app_config=config, + mcp_server_url=mcp_server_url, ) native_tools.append(delegate_tool)