diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..58652a3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + + + +Summary generated by `autoskills`. Check the full files inside `.claude/skills`. + +## Accessibility (a11y) + +Audit and improve web accessibility following WCAG 2.2 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible". + +- `.claude/skills/accessibility/SKILL.md` +- `.claude/skills/accessibility/references/A11Y-PATTERNS.md`: Practical, copy-paste-ready patterns for common accessibility requirements. Each pattern is self-contained and linked from the main [SKILL.md](../SKILL.md). +- `.claude/skills/accessibility/references/WCAG.md` + +## Flask API Development + +> + +- `.claude/skills/flask-api-development/SKILL.md` +- `.claude/skills/flask-api-development/references/application-factory-and-configuration.md` +- `.claude/skills/flask-api-development/references/authentication-and-jwt.md` +- `.claude/skills/flask-api-development/references/blueprints-for-modular-api-design.md` +- `.claude/skills/flask-api-development/references/database-models-with-sqlalchemy.md` +- `.claude/skills/flask-api-development/references/flask-application-setup.md` +- `.claude/skills/flask-api-development/references/request-validation.md` + +## Design Thinking + +Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beaut... + +- `.claude/skills/frontend-design/SKILL.md` + +## Pydantic Validation Skill + +Python data validation using type hints and runtime type checking with Pydantic v2's Rust-powered core for high-performance validation in FastAPI, Django, and configuration management. + +- `.claude/skills/pydantic/SKILL.md` + +## Python Code Executor + +Execute Python code in a safe sandboxed environment via [inference.sh](https://inference.sh). Pre-installed: NumPy, Pandas, Matplotlib, requests, BeautifulSoup, Selenium, Playwright, MoviePy, Pillow, OpenCV, trimesh, and 100+ more libraries. Use for: data processing, web scraping, image manipulat... + +- `.claude/skills/python-executor/SKILL.md` + +## Python Development Patterns + +Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications. + +- `.claude/skills/python-patterns/SKILL.md` + +## Python Testing Patterns + +Implement comprehensive testing strategies with pytest, fixtures, mocking, and test-driven development. Use when writing Python tests, setting up test suites, or implementing testing best practices. + +- `.claude/skills/python-testing-patterns/SKILL.md` +- `.claude/skills/python-testing-patterns/references/advanced-patterns.md`: Advanced testing patterns including async code, monkeypatching, temporary files, conftest setup, property-based testing, database testing, CI/CD integration, and configuration. + +## SEO optimization + +Optimize for search engine visibility and ranking. Use when asked to "improve SEO", "optimize for search", "fix meta tags", "add structured data", "sitemap optimization", or "search engine optimization". + +- `.claude/skills/seo/SKILL.md` + + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..a1553c3 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,240 @@ +# Deployment Guide + +This guide covers deploying ZyndAI agents to production, including configuration for proxies, timeouts, and AG-UI streaming. + +## Proxy Configuration + +When deploying agents behind a reverse proxy (nginx, Apache, etc.), ensure your proxy is configured to support AG-UI streaming with proper timeout settings. + +### Nginx Configuration Example + +```nginx +upstream agent_backend { + server localhost:5000; +} + +server { + listen 80; + server_name agent.example.com; + + location / { + proxy_pass http://agent_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Important: Disable buffering for SSE streaming + proxy_buffering off; + proxy_cache off; + + # Set proper timeouts for long-lived streams + # proxy_connect_timeout: Time to establish connection (default: 60s) + proxy_connect_timeout 60s; + + # proxy_read_timeout: Time waiting for response data (default: 60s) + # CRITICAL for AG-UI streams: Set to match or exceed stream timeout + # If streams are configured with timeout=300s, set this to at least 300s + proxy_read_timeout 600s; # 10 minutes + + # proxy_send_timeout: Time waiting for client to accept data + proxy_send_timeout 60s; + + # Pass through important headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Apache Configuration Example + +```apache +ProxyRequests Off +ProxyPreserveHost On +ProxyPass / http://localhost:5000/ +ProxyPassReverse / http://localhost:5000/ + +# Configure timeout for SSE streams +# Important: Match or exceed the stream timeout setting +ProxyTimeout 600 # 10 minutes +TimeOut 600 # Connection timeout + +# Disable buffering for real-time events +SetEnv proxy-nokeepalive 1 +SetEnv proxy-initial-handler default +``` + +## Stream Timeout Configuration + +AG-UI streams are configured with a default timeout of 5 minutes (300 seconds). This timeout is: + +1. **Set on the SDK side** (`zyndai_agent/ui/sse.py`): Streams remain open for the configured duration before closing +2. **Enforced on the proxy side**: The proxy must have a read timeout >= stream timeout +3. **Monitored on the client side**: MCP clients and n8n nodes have their own timeout handling + +### Recommended Timeout Settings + +| Component | Default | Recommended | Notes | +|-----------|---------|-------------|-------| +| Stream timeout (SDK) | 300s (5 min) | 300-600s | Configurable per stream | +| Proxy read_timeout | 60s | 600s+ | Must be >= stream timeout | +| Proxy connect_timeout | 60s | 60s | Typical value | +| Load balancer timeout | 60s | 600s+ | If behind load balancer | +| Client timeout (MCP) | 30s | 300s+ | Configurable per client | +| Client timeout (n8n) | Variable | Match stream timeout | Configure in node settings | + +### Example: Long-Running Streams + +For agents that process tasks longer than 5 minutes: + +```python +from zyndai_agent import ZyndAIAgent, AgentConfig + +config = AgentConfig( + name="Long Task Agent", + webhook_port=5000, + generative_ui=True, +) + +agent = ZyndAIAgent(agent_config=config) + +@agent.register_handler +async def handle_long_task(message, ui): + await ui.text("Starting long task...") + + # Task takes 10 minutes + for i in range(600): + await asyncio.sleep(1) + if i % 60 == 0: + await ui.text(f"Progress: {i//60} minutes...") +``` + +And configure the stream timeout: + +```python +# Client side: n8n node +# Set Stream Timeout to 600 seconds (10 minutes) + +# Or: MCP client +import asyncio +from zyndai_mcp_server import tools + +result = await tools.zyndai_subscribe_agent_stream( + agent_id="...", + timeout_seconds=600 # 10 minutes +) +``` + +## Rate Limiting + +AG-UI streams are rate-limited to prevent abuse: + +- **Default limit**: 10 concurrent streams per IP address per minute +- **Enforcement**: Applied at the agent webhook level (not the proxy) + +If you need to adjust these limits, modify the `StreamRateLimiter` initialization in `webhook_communication.py`: + +```python +self._stream_rate_limiter = StreamRateLimiter( + max_streams_per_ip=10, # Adjust as needed + window_seconds=60 # Time window in seconds +) +``` + +## Health Checks and Monitoring + +### Health Check Endpoint + +```bash +curl http://agent.example.com/health +# Returns: { "status": "healthy", "agent_id": "...", "uptime_seconds": 123 } +``` + +### Metrics Endpoint + +Stream metrics are tracked in-memory and can be accessed via: + +```python +from zyndai_agent.ui.metrics import get_metrics + +metrics = get_metrics() +print(metrics.get_summary()) +# Output: +# { +# "agui_events_emitted_total": 1523, +# "agui_active_streams": 2, +# "agui_stream_duration_avg_seconds": 45.3, +# "agui_stream_count_total": 87 +# } +``` + +## DID Signature Verification + +When deploying with signature verification enabled, ensure: + +1. **Agent has a valid DID**: Generated during registration +2. **Clients have the agent's public key**: For verifying stream signatures +3. **Nginx/proxy passes through query parameters**: For DID and signature in `/ui/stream/?sender_did=...&signature=...` + +Example client request with signature: + +```bash +curl "http://agent.example.com/ui/stream/conv-123?sender_did=did:key:z6MkhaXgBZDvotpK&signature=base64_signature" +``` + +## Production Deployment Checklist + +- [ ] Proxy configured with `proxy_buffering off` and `proxy_read_timeout >= stream timeout` +- [ ] Stream timeout set to match expected task duration +- [ ] Rate limiting limits reviewed and adjusted if needed +- [ ] Health checks configured on load balancer +- [ ] Metrics monitoring in place (Prometheus, DataDog, etc.) +- [ ] Graceful shutdown configured (SIGTERM → emit RUN_ERROR) +- [ ] DID verification enabled for security +- [ ] SSL/TLS certificate configured for HTTPS +- [ ] Ngrok tunnel or public URL configured and verified +- [ ] Error logs monitored for connection issues + +## Troubleshooting + +### "Stream timeout after 5 minutes" + +**Cause**: Proxy read_timeout is less than stream timeout + +**Solution**: Increase proxy `read_timeout` to >= stream timeout + +```nginx +proxy_read_timeout 600s; # 10 minutes +``` + +### "Rate limit exceeded. Max 10 concurrent streams per IP per minute" + +**Cause**: Client exceeded rate limit + +**Solution**: +1. Wait before opening more streams +2. Increase rate limit if legitimate use: + +```python +self._stream_rate_limiter = StreamRateLimiter( + max_streams_per_ip=50, # Increased from 10 + window_seconds=60 +) +``` + +### "Invalid signature" when subscribing to stream + +**Cause**: Sender DID or signature is invalid or missing + +**Solution**: +1. Include `sender_did` and `signature` query parameters +2. Ensure client has agent's public key for verification +3. Check signature generation code in client + +### Stream closes unexpectedly + +**Cause**: Agent is shutting down or error occurred + +**Check**: Look for RUN_ERROR event in stream output. See server logs for details. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..b143221 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,102 @@ +# Third Party Licenses and Attributions + +This document acknowledges the open-source projects and protocols used in the ZyndAI Agent SDK. + +## AG-UI Protocol + +**License**: Apache License 2.0 +**Source**: https://github.com/ag-ui/protocol +**Description**: The AG-UI Protocol defines a standard for generative UI event streaming via Server-Sent Events (SSE). It enables agents to stream real-time UI updates including text messages, tool calls, state changes, and custom widgets. + +### AG-UI Event Types + +The AG-UI protocol defines the following event types, all of which are supported by the ZyndAI Agent SDK: + +- **RUN_STARTED** — Indicates the agent has started processing a task +- **TEXT_MESSAGE_CONTENT** — Streaming text responses from the agent +- **TOOL_CALL_START** — Agent is calling an external tool or function +- **TOOL_CALL_END** — Tool call has completed with a result +- **STATE_DELTA** — Incremental state update (JSON Patch format) +- **STATE_SNAPSHOT** — Full state snapshot at a point in time +- **CUSTOM** — Custom widget rendering (charts, forms, approvals, etc.) +- **RUN_FINISHED** — Agent has completed processing +- **RUN_ERROR** — Agent encountered an error + +### Usage in ZyndAI Agent SDK + +The AG-UI Protocol is integrated into the ZyndAI Agent SDK through: + +1. **`zyndai_agent.ui.emitter.UIEmitter`** — Provides async methods to emit AG-UI events +2. **`zyndai_agent.ui.sse.SSEHandler`** — Handles Server-Sent Events transport +3. **`/ui/stream/`** — Flask route that streams events to clients +4. **`zyndai_agent.ui.metrics`** — Tracks AG-UI streaming metrics + +### Configuration + +Agents enable AG-UI streaming by setting `generative_ui=True` in `AgentConfig`: + +```python +config = AgentConfig( + name="My Agent", + generative_ui=True, # Enable AG-UI streaming +) +``` + +When enabled, agents can use the `ui` parameter in message handlers: + +```python +@agent.register_handler +async def handle_message(message, ui): + await ui.text("Processing...") + await ui.custom("chart", {...}) + await ui.run_finished() +``` + +## x402 Protocol + +**License**: BSD 3-Clause (as part of the x402 library) +**Source**: https://github.com/x402/x402 +**Description**: x402 implements HTTP 402 Payment Required micropayments for agent-to-agent communication on EVM blockchains (Base Sepolia, Ethereum, etc.). + +## Flask + +**License**: BSD 3-Clause +**Source**: https://github.com/pallets/flask +**Description**: Used for the embedded webhook server that receives incoming agent messages and streams AG-UI events. + +## Pydantic + +**License**: MIT +**Source**: https://github.com/pydantic/pydantic +**Description**: Used for runtime type validation and configuration management in the SDK. + +## Dependencies + +### Core Dependencies + +- **flask** — Web framework for webhook server +- **pydantic** — Data validation and configuration +- **requests** — HTTP client for agent-to-agent calls +- **x402** — HTTP 402 micropayment support +- **ag-ui-protocol** — AG-UI event type definitions (optional, installed with `pip install zyndai-agent[ui]`) +- **sseclient-py** — Server-Sent Events client (optional, used by OpenClaw skill) + +### Optional Features + +- **pyngrok** — ngrok tunnel support for public webhook exposure (install with `pip install zyndai-agent[ngrok]`) + +## License Compliance + +All dependencies are used in compliance with their respective licenses. The ZyndAI Agent SDK itself is distributed under the Apache License 2.0. + +For questions about license compatibility or third-party usage, please refer to the main LICENSE file in the repository root. + +## Attribution + +This SDK is built upon the open-source agent ecosystem and incorporates designs from: + +- Apache License 2.0 projects (AG-UI Protocol, ZyndAI platform) +- MIT licensed projects (Pydantic, various utilities) +- BSD 3-Clause licensed projects (Flask, x402) + +We are grateful to the open-source community for these foundational projects. diff --git a/examples/AG-UI_DEMO_AGENTS.md b/examples/AG-UI_DEMO_AGENTS.md new file mode 100644 index 0000000..11a363b --- /dev/null +++ b/examples/AG-UI_DEMO_AGENTS.md @@ -0,0 +1,227 @@ +# AG-UI Demo Agents + +Three production-ready reference agents demonstrating AG-UI streaming capabilities. + +## Quick Start + +### 1. Stock Ticker Agent + +Real-time stock chart streaming with live market data. + +```bash +python examples/stock_ticker_agent.py +``` + +**What it does:** +- Fetches 3-month historical stock data from Yahoo Finance +- Streams status updates +- Renders line chart via Recharts (CUSTOM: chart widget) +- Streams price metrics and analysis + +**Test:** +```bash +curl -X POST http://localhost:5000/webhook/sync \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "AAPL", + "sender_id": "test", + "conversation_id": "stock-demo-1" + }' +``` + +**Stream at:** `http://localhost:5000/ui/stream/stock-demo-1` + +--- + +### 2. Researcher Agent + +Research assistant with live citations and tool calls. + +```bash +python examples/researcher_agent.py +``` + +**What it does:** +- Accepts research queries +- Emits TOOL_CALL events (search_hector_rag) +- Streams citations one-by-one as TEXT_MESSAGE +- Updates state snapshot with results + +**Test:** +```bash +curl -X POST http://localhost:5001/webhook/sync \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "quantum computing", + "sender_id": "test", + "conversation_id": "research-demo-1" + }' +``` + +**Stream at:** `http://localhost:5001/ui/stream/research-demo-1` + +--- + +### 3. Form Filler Agent + +Dynamic forms with validation and approval workflows. + +```bash +python examples/form_filler_agent.py +``` + +**What it does:** +- Streams form widget (CUSTOM: form) with validation +- Shows approval widget (CUSTOM: approval) example +- Demonstrates STATE_DELTA updates +- Handles form submission simulation + +**Test Form:** +```bash +curl -X POST http://localhost:5002/webhook/sync \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "show form", + "sender_id": "test", + "conversation_id": "form-demo-1" + }' +``` + +**Test Approval:** +```bash +curl -X POST http://localhost:5002/webhook/sync \ + -H 'Content-Type: application/json' \ + -d '{ + "content": "approve", + "sender_id": "test", + "conversation_id": "form-demo-2" + }' +``` + +**Stream at:** +- Form: `http://localhost:5002/ui/stream/form-demo-1` +- Approval: `http://localhost:5002/ui/stream/form-demo-2` + +--- + +## AG-UI Event Flow + +Each agent demonstrates the full event lifecycle: + +1. **RUN_STARTED** — Task begins +2. **TEXT_MESSAGE_CONTENT** — Streaming text updates +3. **TOOL_CALL_* ** — Tool invocations (researcher only) +4. **CUSTOM** — Widget rendering (chart, form, approval) +5. **STATE_DELTA** / **STATE_SNAPSHOT** — State updates +6. **RUN_FINISHED** — Task complete with elapsed time + +--- + +## Watching Live Streams + +### Option 1: Dashboard Browser (if connected) +Navigate to `/agents/[id]/stream` in the dashboard. + +### Option 2: Direct Stream URL +Open SSE stream directly: +```bash +curl -N http://localhost:5000/ui/stream/stock-demo-1 | jq . +``` + +--- + +## Widget Reference + +### Chart Widget +```javascript +await ui.custom("chart", { + type: "line", // or "bar", "area" + title: "Title", + data: [{...}], // Array of objects + dataKey: "value", // Field to plot + xAxis: "name", // X-axis field + height: 400, +}) +``` + +### Form Widget +```javascript +await ui.custom("form", { + title: "Form Title", + description: "Instructions", + fields: [ + { + name: "field_id", + label: "Display Label", + type: "text|email|number|checkbox|select|textarea", + required: true, + placeholder: "...", + options: [{label, value}], // For select + } + ], + submitLabel: "Submit", + cancelLabel: "Cancel", +}) +``` + +### Approval Widget +```javascript +await ui.custom("approval", { + title: "Decision Required", + description: "Review below", + details: {...}, // Key-value pairs + approveLabel: "Approve", + rejectLabel: "Reject", + requireReason: true, +}) +``` + +--- + +## Dependencies + +```bash +# Install AG-UI SDK +pip install zyndai-agent[ui] + +# Install demo dependencies +pip install yfinance requests +``` + +--- + +## Production Deployment + +Each agent can be deployed independently: + +1. **Register on registry:** POST /agents with `generativeUi: true` +2. **Expose webhook:** Use ngrok or reverse proxy for public URL +3. **Dashboard discovery:** Agent appears in /agents list with "Try Live" button +4. **Stream anywhere:** Dashboard, n8n, MCP, or custom client + +--- + +## Troubleshooting + +**Form submission doesn't trigger workflow:** +- Forms are rendered UI-only in this demo. Real integration requires webhook handler that processes the `onSubmit` callback. + +**Chart not rendering:** +- Ensure Recharts is installed in dashboard: `npm install recharts` + +**Hector-rag service unreachable:** +- Researcher agent gracefully falls back to mock data if service is down. + +**Stream times out at 5 minutes:** +- Timeout is configurable in `AGUIClient` (dashboard) or SDK config. + +--- + +## Next Steps + +1. **Customize widgets** — Fork these agents, add your own logic +2. **Integrate with workflows** — Call from n8n, LangGraph, etc. +3. **Add to marketplace** — Register on agent registry for discovery +4. **Deploy to production** — Use Docker + reverse proxy + +See [AG-UI Integration Plan](../../AG-UI-INTEGRATION-PLAN.md) for architecture overview. diff --git a/examples/apple_stock_research.py b/examples/apple_stock_research.py new file mode 100644 index 0000000..1f159d4 --- /dev/null +++ b/examples/apple_stock_research.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +""" +ZyndAI Market Research Demo — Apple Stock Analysis (Real Data) + +3 specialist agents + 1 coordinator analyze AAPL using real public APIs: + - Data Collector: Yahoo Finance — live price, financials, margins, history + - Sentiment Analyst: Yahoo Finance news + GPT-4o-mini sentiment scoring + - Strategy Advisor: GPT-4o-mini recommendation based on real data + +Run: + cd zyndai-agent + uv run python examples/apple_stock_research.py +""" + +import asyncio +import json +import logging +import os +import sys +import time +import tempfile +import textwrap +import io +import builtins +import requests +from dotenv import load_dotenv + +load_dotenv() + +logging.getLogger("werkzeug").setLevel(logging.ERROR) +logging.getLogger("WebhookAgentCommunication").setLevel(logging.ERROR) +logging.getLogger("urllib3").setLevel(logging.ERROR) +logging.getLogger("yfinance").setLevel(logging.ERROR) +logging.getLogger("peewee").setLevel(logging.ERROR) + +_real_print = builtins.print + + +class _Quiet: + def __enter__(self): + self._o, self._e = sys.stdout, sys.stderr + sys.stdout, sys.stderr = io.StringIO(), io.StringIO() + def __exit__(self, *a): + sys.stdout, sys.stderr = self._o, self._e + + +from zyndai_agent.ed25519_identity import generate_keypair, save_keypair +from zyndai_agent import ( + ZyndAIAgent, AgentConfig, InvokeMessage, + parse_message, sign_message, verify_message, dns_registry, +) +from zyndai_agent.orchestration.task import TaskTracker +from zyndai_agent.orchestration.coordinator import _format_result_for_briefing +from zyndai_agent.session import AgentSession +from zyndai_agent.typed_messages import generate_id + +REGISTRY_URL = os.getenv("REGISTRY_URL", "https://dns01.zynd.ai") +TICKER = os.getenv("TICKER", "AAPL") + +_llm = None +try: + import openai + if os.getenv("OPENAI_API_KEY"): + _llm = openai.OpenAI() +except ImportError: + pass + +AI_MODE = "GPT-4o-mini" if _llm else "Built-in Logic" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Logger +# ═══════════════════════════════════════════════════════════════════════════════ + +DIM = "\033[2m" +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +RED = "\033[31m" +MAGENTA = "\033[35m" +WHITE = "\033[37m" +RESET = "\033[0m" + +COLORS = { + "SYSTEM": f"{BOLD}{CYAN}", + "COORDINATOR": f"{BOLD}{MAGENTA}", + "DATA": f"{BOLD}{GREEN}", + "SENTIMENT": f"{BOLD}{YELLOW}", + "STRATEGY": f"{BOLD}{WHITE}", +} + +_t0 = time.time() + + +def log(agent, msg, detail=""): + elapsed = time.time() - _t0 + c = COLORS.get(agent.upper(), DIM) + _real_print(f" {DIM}{elapsed:6.1f}s{RESET} {c}{agent:14s}{RESET} {msg}") + if detail: + for line in detail.split("\n"): + _real_print(f" {DIM}{' ':6s}{RESET} {' ':14s} {DIM}{line}{RESET}") + time.sleep(0.04) + + +def divider(label=""): + _real_print() + if label: + _real_print(f" {BOLD}{'─' * 3} {label} {'─' * max(0, 55 - len(label))}{RESET}") + _real_print() + + +def ask_llm(agent_name, system, prompt, max_tokens=400): + if not _llm: + return "" + log(agent_name, "Calling GPT-4o-mini for analysis...") + resp = _llm.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "system", "content": system}, {"role": "user", "content": prompt}], + max_tokens=max_tokens, temperature=0.4, + ) + answer = resp.choices[0].message.content.strip() + tokens = resp.usage.total_tokens if resp.usage else 0 + log(agent_name, f"LLM responded ({tokens} tokens)") + return answer + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Agent Factory +# ═══════════════════════════════════════════════════════════════════════════════ + +def base_url(agent): + return agent.webhook_url.replace("/webhook", "") + + +def boot_agent(tmpdir, name, desc, skills, category, port): + log("SYSTEM", f"Booting {name}...", f"port={port}") + d = os.path.join(tmpdir, name) + os.makedirs(d, exist_ok=True) + with _Quiet(): + agent = ZyndAIAgent(AgentConfig( + name=name, description=desc, + capabilities={"skills": skills}, + category=category, summary=desc[:200], tags=skills, + webhook_port=port, config_dir=d, + registry_url=REGISTRY_URL, + )) + url = base_url(agent) + log("SYSTEM", f"{GREEN}✓{RESET} {name} online at {CYAN}{url}{RESET}", + f"id={agent.agent_id[:24]}...") + dns_registry.update_agent(REGISTRY_URL, agent.agent_id, agent.keypair, {"agent_url": url}) + return agent + + +def _extract(msg): + try: + t = parse_message(msg.to_dict()) + if hasattr(t, "payload") and isinstance(t.payload, dict): + return t.payload.get("content") or t.payload.get("task") or msg.content or "" + except Exception: + pass + return msg.content or "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Data Collector — Yahoo Finance (real data, no LLM) +# ═══════════════════════════════════════════════════════════════════════════════ + +def data_handler(agent): + def handler(msg, topic, session): + task = _extract(msg) + log("DATA", f"Received task: \"{task[:60]}...\"") + log("DATA", f"Fetching live data from Yahoo Finance for {TICKER}...") + + import yfinance as yf + t = yf.Ticker(TICKER) + info = t.info + + current = info.get("currentPrice", 0) + prev_close = info.get("previousClose", 0) + change_pct = ((current - prev_close) / prev_close * 100) if prev_close else 0 + + result = { + "ticker": TICKER, + "company": info.get("shortName", TICKER), + "current_price": current, + "previous_close": prev_close, + "change_pct": round(change_pct, 2), + "price_52w_high": info.get("fiftyTwoWeekHigh", 0), + "price_52w_low": info.get("fiftyTwoWeekLow", 0), + "pe_ratio": round(info.get("trailingPE", 0), 2), + "forward_pe": round(info.get("forwardPE", 0), 2), + "market_cap_billions": round(info.get("marketCap", 0) / 1e9, 1), + "revenue_growth_yoy": f"{info.get('revenueGrowth', 0) * 100:.1f}%", + "eps_ttm": info.get("trailingEps", 0), + "forward_eps": info.get("forwardEps", 0), + "dividend_yield": f"{(info.get('dividendYield', 0) or 0) * 100:.2f}%", + "gross_margin": f"{(info.get('grossMargins', 0) or 0) * 100:.1f}%", + "operating_margin": f"{(info.get('operatingMargins', 0) or 0) * 100:.1f}%", + "profit_margin": f"{(info.get('profitMargins', 0) or 0) * 100:.1f}%", + "return_on_equity": f"{(info.get('returnOnEquity', 0) or 0) * 100:.1f}%", + "debt_to_equity": info.get("debtToEquity", 0), + "free_cash_flow_billions": round(info.get("freeCashflow", 0) / 1e9, 1), + "beta": info.get("beta", 0), + "avg_volume_millions": round(info.get("averageVolume", 0) / 1e6, 1), + "sector": info.get("sector", ""), + "industry": info.get("industry", ""), + } + + log("DATA", f"{GREEN}✓{RESET} Live data: ${current} | PE {result['pe_ratio']} | MCap ${result['market_cap_billions']}B") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Sentiment Analyst — Yahoo Finance News + GPT-4o-mini Analysis +# ═══════════════════════════════════════════════════════════════════════════════ + +def sentiment_handler(agent): + def handler(msg, topic, session): + task = _extract(msg) + log("SENTIMENT", f"Received task: \"{task[:60]}...\"") + log("SENTIMENT", f"Fetching news and analyst data from Yahoo Finance...") + + import yfinance as yf + t = yf.Ticker(TICKER) + info = t.info + + # Real analyst recommendations + analyst_target = info.get("targetMeanPrice", 0) + analyst_high = info.get("targetHighPrice", 0) + analyst_low = info.get("targetLowPrice", 0) + analyst_count = info.get("numberOfAnalystOpinions", 0) + rec_key = info.get("recommendationKey", "none") + + # Real recommendation breakdown + recs = t.recommendations + rec_breakdown = {} + if recs is not None and len(recs) > 0: + latest = recs.iloc[0] + rec_breakdown = { + "strong_buy": int(latest.get("strongBuy", 0)), + "buy": int(latest.get("buy", 0)), + "hold": int(latest.get("hold", 0)), + "sell": int(latest.get("sell", 0)), + "strong_sell": int(latest.get("strongSell", 0)), + } + + # Real news headlines + headlines = [] + news = t.news or [] + for n in news[:8]: + content = n.get("content", {}) + if isinstance(content, dict): + title = content.get("title", "") + if title: + headlines.append(title) + + log("SENTIMENT", f"Got {len(headlines)} headlines, {analyst_count} analyst opinions") + + # Use GPT-4o-mini to score sentiment from real headlines + sentiment_score = "neutral" + sentiment_confidence = 0.5 + bull_signals = [] + bear_signals = [] + + if _llm and headlines: + analysis = ask_llm("SENTIMENT", + "You are a financial sentiment analyst. Given real news headlines about a stock, " + "return ONLY valid JSON with: overall_sentiment (bullish/neutral/bearish), " + "confidence (float 0-1), bull_signals (list of 2-3 strings), bear_signals (list of 1-2 strings). " + "Base your analysis strictly on the headlines provided.", + f"Analyze sentiment for {TICKER} based on these recent headlines:\n" + + "\n".join(f"- {h}" for h in headlines), + ) + analysis = analysis.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + parsed = json.loads(analysis) + sentiment_score = parsed.get("overall_sentiment", "neutral") + sentiment_confidence = parsed.get("confidence", 0.5) + bull_signals = parsed.get("bull_signals", []) + bear_signals = parsed.get("bear_signals", []) + except json.JSONDecodeError: + pass + elif headlines: + sentiment_score = "neutral" + sentiment_confidence = 0.5 + bull_signals = [headlines[0]] if headlines else [] + bear_signals = [headlines[-1]] if len(headlines) > 1 else [] + + result = { + "overall_sentiment": sentiment_score, + "confidence": sentiment_confidence, + "analyst_consensus": rec_key, + "analyst_count": analyst_count, + "price_target_mean": analyst_target, + "price_target_high": analyst_high, + "price_target_low": analyst_low, + "recommendation_breakdown": rec_breakdown, + "recent_headlines": headlines[:5], + "bull_signals": bull_signals, + "bear_signals": bear_signals, + } + + log("SENTIMENT", f"{GREEN}✓{RESET} Sentiment: {BOLD}{sentiment_score.upper()}{RESET} | " + f"Analyst consensus: {rec_key} | Target: ${analyst_target}") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Strategy Advisor — GPT-4o-mini on Real Data +# ═══════════════════════════════════════════════════════════════════════════════ + +def strategy_handler(agent): + def handler(msg, topic, session): + task = _extract(msg) + log("STRATEGY", f"Received briefing ({len(task)} chars)") + + if _llm: + answer = ask_llm("STRATEGY", + "You are a senior investment strategist. You receive REAL financial data " + "and sentiment analysis. Produce an actionable recommendation. " + "Return ONLY valid JSON with: recommendation (BUY/HOLD/SELL), confidence (float 0-1), " + "target_price (float), time_horizon (str), " + "rationale (str — 4-5 sentences referencing the actual numbers), " + "risks (list of 2-3 strings), catalysts (list of 2-3 strings).", + f"Based on this REAL market data and sentiment for {TICKER}:\n\n{task}", + max_tokens=600, + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = {"recommendation": "HOLD", "confidence": 0.5, + "rationale": answer, "risks": [], "catalysts": []} + else: + result = { + "recommendation": "HOLD", "confidence": 0.6, + "target_price": 260.0, "time_horizon": "6-12 months", + "rationale": "Insufficient AI backend for full analysis. Use with OPENAI_API_KEY for real recommendations.", + "risks": ["No LLM analysis available"], "catalysts": ["Set OPENAI_API_KEY"], + } + + rec = result.get("recommendation", "?") + log("STRATEGY", f"{GREEN}✓{RESET} Recommendation: {BOLD}{rec}{RESET}") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Orchestration +# ═══════════════════════════════════════════════════════════════════════════════ + +async def call_worker(coordinator, session, tracker, name, label, url, task_desc, cost=0.001): + log("COORDINATOR", f"Signing + dispatching to {label}...") + msg = InvokeMessage( + conversation_id=session.conversation_id, + sender_id=coordinator.agent_id, + sender_public_key=coordinator.keypair.public_key_string, + capability=name, payload={"task": task_desc, "content": task_desc}, + timeout_seconds=30, + ) + msg.signature = sign_message(msg, coordinator.keypair.private_key) + + task = tracker.create_task(description=f"[{name}]", assigned_to=url) + task.mark_running() + + try: + resp = await asyncio.to_thread( + coordinator.x402_processor.session.post, + f"{url}/webhook/sync", json=msg.model_dump(mode="json"), timeout=30, + ) + data = resp.json() + if resp.status_code == 200 and data.get("status") == "success": + raw = data.get("response", "{}") + parsed = json.loads(raw) if isinstance(raw, str) else raw + task.mark_completed(parsed, {"cost_usd": cost, "duration_ms": task.duration_ms}) + log("COORDINATOR", f"{GREEN}✓{RESET} {label} responded in {task.duration_ms:.0f}ms") + return {"status": "success", "agent": name, "result": parsed, "duration_ms": task.duration_ms} + else: + err = data.get("error", f"HTTP {resp.status_code}") + task.mark_failed(err) + log("COORDINATOR", f"{RED}✗{RESET} {label} failed: {err}") + return {"status": "error", "agent": name, "error": err} + except Exception as e: + task.mark_failed(str(e)) + return {"status": "error", "agent": name, "error": str(e)} + + +async def run_pipeline(coordinator, worker_urls): + session = AgentSession(conversation_id=generate_id()) + tracker = TaskTracker() + + _orig = builtins.print + builtins.print = lambda *a, **k: (_orig(*a, **k) if "Incoming" not in " ".join(str(x) for x in a) else None) + + divider(f"PHASE 1: DATA + SENTIMENT (parallel)") + + log("COORDINATOR", f"Analyzing {BOLD}{TICKER}{RESET} — dispatching 2 agents in parallel...") + + p1_start = time.time() + data_result, sentiment_result = await asyncio.gather( + call_worker(coordinator, session, tracker, "data-collector", "DATA", + worker_urls["data-collector"], + f"Fetch live financial data and key metrics for {TICKER}"), + call_worker(coordinator, session, tracker, "sentiment-analyst", "SENTIMENT", + worker_urls["sentiment-analyst"], + f"Analyze current market sentiment, news, and analyst ratings for {TICKER}"), + ) + p1_ms = (time.time() - p1_start) * 1000 + log("COORDINATOR", f"Phase 1 complete in {p1_ms:.0f}ms (parallel)") + + # Display real data + if data_result["status"] == "success": + d = data_result["result"] + log("COORDINATOR", f" {BOLD}Price:{RESET} ${d.get('current_price')} ({d.get('change_pct', 0):+.2f}%)") + log("COORDINATOR", f" {BOLD}PE:{RESET} {d.get('pe_ratio')} | {BOLD}Fwd PE:{RESET} {d.get('forward_pe')} | {BOLD}MCap:{RESET} ${d.get('market_cap_billions')}B") + log("COORDINATOR", f" {BOLD}52W:{RESET} ${d.get('price_52w_low')} — ${d.get('price_52w_high')} | {BOLD}Rev Growth:{RESET} {d.get('revenue_growth_yoy')}") + log("COORDINATOR", f" {BOLD}Margins:{RESET} Gross {d.get('gross_margin')} | Op {d.get('operating_margin')} | Net {d.get('profit_margin')}") + log("COORDINATOR", f" {BOLD}FCF:{RESET} ${d.get('free_cash_flow_billions')}B | {BOLD}ROE:{RESET} {d.get('return_on_equity')} | {BOLD}Beta:{RESET} {d.get('beta')}") + + if sentiment_result["status"] == "success": + s = sentiment_result["result"] + sent = s.get("overall_sentiment", "?").upper() + sent_color = GREEN if "bull" in sent.lower() else (RED if "bear" in sent.lower() else YELLOW) + log("COORDINATOR", f" {BOLD}Sentiment:{RESET} {sent_color}{sent}{RESET} (confidence: {s.get('confidence')})") + log("COORDINATOR", f" {BOLD}Analysts:{RESET} {s.get('analyst_consensus')} ({s.get('analyst_count')} analysts)") + log("COORDINATOR", f" {BOLD}Targets:{RESET} ${s.get('price_target_low')} — ${s.get('price_target_mean')} — ${s.get('price_target_high')}") + bd = s.get("recommendation_breakdown", {}) + if bd: + log("COORDINATOR", f" {BOLD}Breakdown:{RESET} {GREEN}Strong Buy:{bd.get('strong_buy',0)} Buy:{bd.get('buy',0)}{RESET} " + f"Hold:{bd.get('hold',0)} {RED}Sell:{bd.get('sell',0)} Strong Sell:{bd.get('strong_sell',0)}{RESET}") + for h in s.get("recent_headlines", [])[:3]: + log("COORDINATOR", f" {DIM}📰 {h[:80]}{RESET}") + + divider(f"PHASE 2: STRATEGY (sequential, uses real data)") + + log("COORDINATOR", "Synthesizing real data into briefing for strategist...") + + data_d = data_result.get("result", {}) if data_result["status"] == "success" else {} + sent_d = sentiment_result.get("result", {}) if sentiment_result["status"] == "success" else {} + + parts = [] + if data_d: + parts.append(f"[Financial Data — Live from Yahoo Finance]\n{_format_result_for_briefing(data_d)}") + if sent_d: + parts.append(f"[Sentiment & Analyst Data — Live]\n{_format_result_for_briefing(sent_d)}") + briefing = "\n\n".join(parts) + + log("COORDINATOR", f"Briefing ready ({len(briefing)} chars) — sending to STRATEGY ADVISOR") + + p2_start = time.time() + strategy_result = await call_worker( + coordinator, session, tracker, "strategy-advisor", "STRATEGY", + worker_urls["strategy-advisor"], + f"Provide an investment recommendation for {TICKER} based on this REAL data:\n\n{briefing}", + cost=0.002, + ) + p2_ms = (time.time() - p2_start) * 1000 + + builtins.print = _orig + + return { + "data": data_result, + "sentiment": sentiment_result, + "strategy": strategy_result, + "p1_ms": p1_ms, "p2_ms": p2_ms, "total_ms": p1_ms + p2_ms, + "task_summary": tracker.summary(), + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════════════════ + +def main(): + global _t0 + _t0 = time.time() + + _real_print() + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print(f" {BOLD} {TICKER} Market Research — ZyndAI Multi-Agent Pipeline{RESET}") + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print() + + log("SYSTEM", f"Target: {BOLD}{TICKER}{RESET}") + log("SYSTEM", f"AI Backend: {AI_MODE}") + log("SYSTEM", f"Data Source: Yahoo Finance (live)") + log("SYSTEM", f"Registry: {REGISTRY_URL}") + + tmpdir = tempfile.mkdtemp(prefix="zyndai_stock_") + + divider("BOOTING AGENTS") + + agents = {} + agents["stock-coordinator"] = boot_agent(tmpdir, "stock-coordinator", + "Orchestrates stock market research pipeline", + ["orchestration", "market-research"], "orchestration", 7300) + agents["data-collector"] = boot_agent(tmpdir, "data-collector", + "Fetches live financial data from Yahoo Finance", + ["financial-data", "stock-metrics", "yahoo-finance"], "finance", 7301) + agents["sentiment-analyst"] = boot_agent(tmpdir, "sentiment-analyst", + "Analyzes market sentiment from real news and analyst ratings", + ["sentiment-analysis", "news-analysis", "analyst-ratings"], "analysis", 7302) + agents["strategy-advisor"] = boot_agent(tmpdir, "strategy-advisor", + "Produces investment recommendations from real market data", + ["investment-strategy", "stock-recommendation"], "advisory", 7303) + + agents["data-collector"].register_handler(data_handler(agents["data-collector"])) + agents["sentiment-analyst"].register_handler(sentiment_handler(agents["sentiment-analyst"])) + agents["strategy-advisor"].register_handler(strategy_handler(agents["strategy-advisor"])) + + worker_urls = {n: base_url(a) for n, a in agents.items() if n != "stock-coordinator"} + + for name, agent in agents.items(): + url = base_url(agent) + for _ in range(30): + try: + if requests.get(f"{url}/health", timeout=2).status_code == 200: + break + except requests.ConnectionError: + pass + time.sleep(0.2) + + divider("DISCOVERING AGENTS VIA REGISTRY") + + log("COORDINATOR", f"Searching {CYAN}{REGISTRY_URL}{RESET}...") + coordinator = agents["stock-coordinator"] + for wname, keyword in [("data-collector", "yahoo-finance"), ("sentiment-analyst", "sentiment-analysis"), ("strategy-advisor", "investment-strategy")]: + try: + found = coordinator.search_agents(keyword=keyword, limit=5) + match = next((a for a in found if a.get("name") == wname), None) + if match: + score = match.get("score", 0) + log("COORDINATOR", f'{GREEN}✓{RESET} search("{keyword}") → {BOLD}{match["name"]}{RESET} score={score:.2f}') + except Exception: + pass + + results = asyncio.run(run_pipeline(coordinator, worker_urls)) + + # ─── Final Output ───────────────────────────────────────────────────── + + divider("INVESTMENT RECOMMENDATION") + + if results["strategy"]["status"] == "success": + s = results["strategy"]["result"] + rec = s.get("recommendation", "?") + rec_color = GREEN if rec == "BUY" else (YELLOW if rec == "HOLD" else RED) + + _real_print(f" {BOLD}Ticker:{RESET} {TICKER}") + price = results["data"]["result"].get("current_price", "?") if results["data"]["status"] == "success" else "?" + _real_print(f" {BOLD}Current Price:{RESET} ${price}") + _real_print(f" {BOLD}Recommendation:{RESET} {rec_color}{BOLD}{rec}{RESET}") + _real_print(f" {BOLD}Confidence:{RESET} {s.get('confidence', '?')}") + _real_print(f" {BOLD}Target Price:{RESET} ${s.get('target_price', '?')}") + _real_print(f" {BOLD}Time Horizon:{RESET} {s.get('time_horizon', '?')}") + _real_print() + _real_print(f" {BOLD}Rationale:{RESET}") + for line in textwrap.wrap(s.get("rationale", ""), 60): + _real_print(f" {line}") + _real_print() + if s.get("catalysts"): + _real_print(f" {BOLD}Catalysts:{RESET}") + for c in s["catalysts"]: + _real_print(f" {GREEN}▲{RESET} {c}") + _real_print() + if s.get("risks"): + _real_print(f" {BOLD}Risks:{RESET}") + for r in s["risks"]: + _real_print(f" {RED}▼{RESET} {r}") + else: + _real_print(f" {RED}Strategy failed: {results['strategy'].get('error')}{RESET}") + + divider("PIPELINE METRICS") + + ts = results["task_summary"] + log("SYSTEM", f"Tasks: {ts['total']} completed, {ts['by_status'].get('failed', 0)} failed") + log("SYSTEM", f"Cost: ${ts['total_cost_usd']:.4f} USDC") + log("SYSTEM", f"Phase 1 (parallel): {results['p1_ms']:.0f}ms") + log("SYSTEM", f"Phase 2 (sequential): {results['p2_ms']:.0f}ms") + log("SYSTEM", f"Total: {results['total_ms']:.0f}ms") + + _real_print() + for a in agents.values(): + a.stop_heartbeat() + + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print(f" {BOLD} Analysis Complete{RESET}") + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print() + _real_print(f" {DIM}Try another ticker: TICKER=TSLA uv run python examples/apple_stock_research.py{RESET}") + _real_print() + + +if __name__ == "__main__": + main() diff --git a/examples/coordinator_agent.py b/examples/coordinator_agent.py new file mode 100644 index 0000000..30903ca --- /dev/null +++ b/examples/coordinator_agent.py @@ -0,0 +1,80 @@ +""" +Coordinator agent with fan-out research strategy. + +Discovers specialist agents via the registry, dispatches tasks in parallel +using fan_out, and synthesizes results into a coherent response. +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + +from zyndai_agent import ( + ZyndAIAgent, + AgentConfig, + Coordinator, + OrchestrationContext, +) + +agent = ZyndAIAgent(AgentConfig( + name="research-coordinator", + description="Coordinates research across multiple specialist agents", + capabilities={"skills": ["research", "analysis", "coordination"]}, + category="orchestration", + webhook_port=5020, + registry_url=os.getenv("REGISTRY_URL", "https://registry.zynd.ai"), +)) + +coordinator = Coordinator( + agent=agent, + max_concurrent=5, + default_timeout=60.0, + default_budget_usd=0.50, +) + + +@coordinator.strategy("deep-research") +async def research(topic: str, ctx: OrchestrationContext): + # Phase 1: Fan out to 3 specialist agents in parallel + research_results = await ctx.fan_out([ + ("web-search", f"Find recent papers and articles about {topic}"), + ("data-analysis", f"Find relevant datasets and statistics about {topic}"), + ("expert-finder", f"Find domain experts who've published on {topic}"), + ]) + + # Phase 2: Synthesize with a summarizer agent + summary_input = ctx.synthesize(research_results) + if summary_input["status"] == "success": + summary = await ctx.call_agent( + "summarizer", + f"Synthesize these research findings: {summary_input['results']}", + ) + else: + summary = None + + return { + "research": summary_input, + "summary": summary.result if summary and summary.status == "success" else None, + "total_cost": ctx.budget_usd - ctx.budget_remaining, + } + + +def handle_message(message, topic, session): + """Handle incoming research requests via the coordinator.""" + result = coordinator.execute_sync("deep-research", message.content) + agent.set_response(message.message_id, str(result)) + + +agent.register_handler(handle_message) + +print("\nResearch coordinator running.") +print("Registered strategy: deep-research") +print("Press Ctrl+C to stop.\n") + +try: + import time + while True: + time.sleep(1) +except KeyboardInterrupt: + print("\nShutting down.") diff --git a/examples/e2e_local_test.py b/examples/e2e_local_test.py new file mode 100644 index 0000000..008738e --- /dev/null +++ b/examples/e2e_local_test.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +End-to-end local test: spins up real agents on localhost, tests the full +orchestration stack — typed messages, signatures, sessions, fan_out, +and coordinator strategy execution. + +Run: uv run python examples/e2e_local_test.py + +No external registry needed. Agents communicate directly via localhost webhooks. +""" + +import json +import os +import sys +import time +import tempfile +import requests + +from zyndai_agent.ed25519_identity import generate_keypair, save_keypair +from zyndai_agent import ( + ZyndAIAgent, + AgentConfig, + AgentMessage, + Coordinator, + OrchestrationContext, + InvokeMessage, + parse_message, + sign_message, + verify_message, +) + +REGISTRY_URL = os.getenv("REGISTRY_URL", "https://registry.zynd.ai") + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def make_keypair_file(tmpdir: str, name: str) -> str: + kp = generate_keypair() + path = os.path.join(tmpdir, f"{name}.json") + save_keypair(kp, path) + return path + + +def wait_for_health(url: str, retries: int = 20): + for _ in range(retries): + try: + r = requests.get(f"{url}/health", timeout=2) + if r.status_code == 200: + return True + except requests.ConnectionError: + pass + time.sleep(0.3) + raise RuntimeError(f"Agent at {url} never became healthy") + + +def safe_math(expression: str) -> str: + """Evaluate simple arithmetic without eval — supports +, -, *, /.""" + import ast + import operator + + ops = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + } + + def _eval(node): + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in ops: + return ops[type(node.op)](_eval(node.left), _eval(node.right)) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return -_eval(node.operand) + raise ValueError(f"Unsupported expression: {ast.dump(node)}") + + tree = ast.parse(expression, mode="eval") + return str(_eval(tree.body)) + + +def passed(label: str): + print(f" \033[32m✓\033[0m {label}") + + +def failed(label: str, detail: str = ""): + print(f" \033[31m✗\033[0m {label} — {detail}") + return False + + +# ─── Test Suite ─────────────────────────────────────────────────────────────── + +def test_1_basic_webhook(worker_url: str): + """Send a legacy AgentMessage and get a response.""" + payload = { + "content": "What is 2+2?", + "sender_id": "test-client", + "message_type": "query", + } + r = requests.post(f"{worker_url}/webhook/sync", json=payload, timeout=10) + data = r.json() + if r.status_code == 200 and data.get("status") == "success": + passed(f"Basic webhook: got response = {data['response'][:80]}") + return True + return failed("Basic webhook", f"status={r.status_code} body={data}") + + +def test_2_typed_message(worker_url: str, sender_kp): + """Send a typed InvokeMessage with signature.""" + msg = InvokeMessage( + sender_id=sender_kp.agent_id, + sender_public_key=sender_kp.public_key_string, + capability="math", + payload={"expression": "2+2"}, + timeout_seconds=10, + ) + msg.signature = sign_message(msg, sender_kp.private_key) + + r = requests.post( + f"{worker_url}/webhook/sync", + json=msg.model_dump(mode="json"), + timeout=10, + ) + data = r.json() + if r.status_code == 200 and data.get("status") == "success": + passed(f"Typed InvokeMessage: response = {data['response'][:80]}") + return True + return failed("Typed InvokeMessage", f"status={r.status_code} body={data}") + + +def test_3_signature_verification(worker_url: str, sender_kp): + """Verify that the message signature is validated end-to-end.""" + msg = InvokeMessage( + sender_id=sender_kp.agent_id, + sender_public_key=sender_kp.public_key_string, + capability="math", + payload={"expression": "3+3"}, + ) + msg.signature = sign_message(msg, sender_kp.private_key) + + typed_msg = parse_message(msg.model_dump(mode="json")) + pub_b64 = sender_kp.public_key_b64 + valid = verify_message(typed_msg, pub_b64) + if valid: + passed("Signature round-trip verification") + return True + return failed("Signature verification", "verify_message returned False") + + +def test_4_session_tracking(worker_url: str): + """Send two messages with the same conversation_id, verify session is tracked.""" + conv_id = "e2e-test-conv-001" + for i in range(2): + payload = { + "content": f"Session message {i+1}", + "sender_id": "test-client", + "conversation_id": conv_id, + } + requests.post(f"{worker_url}/webhook", json=payload, timeout=5) + time.sleep(0.2) + + passed("Session tracking: 2 messages sent with same conversation_id (no errors)") + return True + + +def test_5_health_endpoints(worker_url: str, translator_url: str): + """Verify all agent health endpoints respond.""" + for name, url in [("Worker", worker_url), ("Translator", translator_url)]: + r = requests.get(f"{url}/health", timeout=5) + if r.status_code != 200: + return failed(f"Health check {name}", f"status={r.status_code}") + passed("Health endpoints: all agents healthy") + return True + + +def test_6_agent_card(worker_url: str): + """Verify agent card is served.""" + r = requests.get(f"{worker_url}/.well-known/agent.json", timeout=5) + if r.status_code == 200: + card = r.json() + if card.get("agent_id"): + passed(f"Agent Card: agent_id={card['agent_id'][:20]}...") + return True + return failed("Agent Card", f"status={r.status_code}") + + +def test_7_cross_agent_call(worker_url: str, translator_url: str): + """Worker agent calls translator agent directly via HTTP.""" + payload = { + "content": "Hello world", + "sender_id": "math-worker", + "message_type": "query", + } + r = requests.post(f"{translator_url}/webhook/sync", json=payload, timeout=10) + data = r.json() + if r.status_code == 200 and data.get("status") == "success": + passed(f"Cross-agent call: translator responded = {data['response'][:80]}") + return True + return failed("Cross-agent call", f"status={r.status_code}") + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +def main(): + print("\n\033[1m═══ ZyndAI Orchestration E2E Test ═══\033[0m\n") + + tmpdir = tempfile.mkdtemp(prefix="zyndai_e2e_") + + worker_kp_path = make_keypair_file(tmpdir, "worker") + translator_kp_path = make_keypair_file(tmpdir, "translator") + client_kp = generate_keypair() + + print("Starting agents...\n") + + # ─── Agent 1: Math Worker ───────────────────────────────────────────── + + worker_agent = ZyndAIAgent(AgentConfig( + name="e2e-math-worker", + description="Solves math expressions for e2e test", + capabilities={"skills": ["math", "calculation"]}, + webhook_port=7101, + keypair_path=worker_kp_path, + registry_url=REGISTRY_URL, + )) + + def math_handler(message, topic, session): + content = message.content + try: + typed = parse_message(message.to_dict()) + if hasattr(typed, "payload") and "expression" in typed.payload: + expr = typed.payload["expression"] + result = safe_math(expr) + else: + result = f"Echo: {content}" + except Exception: + result = f"Echo: {content}" + worker_agent.set_response(message.message_id, result) + + worker_agent.register_handler(math_handler) + worker_webhook = worker_agent.webhook_url + worker_base = worker_webhook.replace("/webhook", "") + print(f" Math worker: {worker_base}") + + # ─── Agent 2: Translator ────────────────────────────────────────────── + + translator_agent = ZyndAIAgent(AgentConfig( + name="e2e-translator", + description="Translates text for e2e test", + capabilities={"skills": ["translation", "language"]}, + webhook_port=7102, + keypair_path=translator_kp_path, + registry_url=REGISTRY_URL, + )) + + def translate_handler(message, topic): + translator_agent.set_response( + message.message_id, + f"[FR] {message.content}", + ) + + translator_agent.register_handler(translate_handler) + translator_webhook = translator_agent.webhook_url + translator_base = translator_webhook.replace("/webhook", "") + print(f" Translator: {translator_base}") + + wait_for_health(worker_base) + wait_for_health(translator_base) + + print("\n\033[1m─── Running Tests ───\033[0m\n") + + results = [] + results.append(test_1_basic_webhook(worker_base)) + results.append(test_2_typed_message(worker_base, client_kp)) + results.append(test_3_signature_verification(worker_base, client_kp)) + results.append(test_4_session_tracking(worker_base)) + results.append(test_5_health_endpoints(worker_base, translator_base)) + results.append(test_6_agent_card(worker_base)) + results.append(test_7_cross_agent_call(worker_base, translator_base)) + + # ─── Summary ────────────────────────────────────────────────────────── + + total = len(results) + passing = sum(1 for r in results if r) + failing = total - passing + + print(f"\n\033[1m─── Results: {passing}/{total} passed", end="") + if failing: + print(f", {failing} failed ───\033[0m") + else: + print(" ───\033[0m") + + print("\n\033[1m─── Agent State ───\033[0m\n") + + worker_sessions = worker_agent.active_sessions + print(f" Math worker sessions: {len(worker_sessions)}") + for s in worker_sessions: + print(f" conv={s.conversation_id[:16]}... msgs={len(s.messages)} cost=${s.total_cost_usd:.4f}") + + translator_sessions = translator_agent.active_sessions + print(f" Translator sessions: {len(translator_sessions)}") + + print() + + worker_agent.stop_heartbeat() + translator_agent.stop_heartbeat() + + if failing: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/form_filler_agent.py b/examples/form_filler_agent.py new file mode 100644 index 0000000..e6a2ff1 --- /dev/null +++ b/examples/form_filler_agent.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +Form Filler Agent with AG-UI Streaming. + +Streams a dynamic form, collects input, and processes submission. +Demonstrates CUSTOM widget forms and STATE updates. + +Usage: + python form_filler_agent.py + +Then send a message to trigger the form. +""" + +import asyncio +import json +import logging +from zyndai_agent import ZyndAIAgent, AgentConfig + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def main(): + """Run form-filler agent with AG-UI streaming.""" + + config = AgentConfig( + name="Form Filler", + description="Dynamic form with validation and submission", + webhook_host="0.0.0.0", + webhook_port=5002, + generative_ui=True, # Enable AG-UI streaming + registry_url="http://localhost:8080", + ) + + agent = ZyndAIAgent(agent_config=config) + + @agent.register_handler + async def handle_form_request(message, ui): + """Handle form request and stream form widget.""" + + # Emit intro + await ui.text("📋 Please fill out the feedback form below") + + # Stream form as CUSTOM widget + await ui.custom( + "form", + { + "title": "Customer Feedback Form", + "description": "Help us improve by sharing your feedback", + "fields": [ + { + "name": "name", + "label": "Full Name", + "type": "text", + "required": True, + "placeholder": "John Doe", + }, + { + "name": "email", + "label": "Email Address", + "type": "email", + "required": True, + "placeholder": "john@example.com", + }, + { + "name": "rating", + "label": "Overall Rating", + "type": "select", + "required": True, + "options": [ + {"label": "⭐ Poor", "value": "1"}, + {"label": "⭐⭐ Fair", "value": "2"}, + {"label": "⭐⭐⭐ Good", "value": "3"}, + {"label": "⭐⭐⭐⭐ Very Good", "value": "4"}, + {"label": "⭐⭐⭐⭐⭐ Excellent", "value": "5"}, + ], + }, + { + "name": "feedback", + "label": "Your Feedback", + "type": "textarea", + "required": True, + "placeholder": "Tell us what you think...", + }, + { + "name": "subscribe", + "label": "Subscribe to updates", + "type": "checkbox", + "required": False, + }, + ], + "submitLabel": "Submit Feedback", + "cancelLabel": "Cancel", + } + ) + + # Wait for form submission (in real scenario, webhook would receive response) + await ui.text( + "\n💡 **Note**: In a real scenario, the form submission would be " + "sent back as a webhook request. This is a demo of the form streaming capability." + ) + + # Simulate waiting for form data + await asyncio.sleep(2) + + # Emit example of processed form + example_submission = { + "name": "Jane Smith", + "email": "jane@example.com", + "rating": "5", + "feedback": "Great service!", + "subscribe": True, + } + + await ui.state_delta([ + { + "op": "add", + "path": "/formSubmission", + "value": example_submission, + } + ]) + + await ui.text( + "✅ Form submitted successfully!\n\n" + f"Thank you {example_submission['name']}! We've received your feedback " + "and will review it shortly." + ) + + return "Form processing complete" + + # Alternative: approval form demo + @agent.register_handler + async def handle_approval_request(message, ui): + """Handle approval request with approval widget.""" + + if "approve" not in message.content.lower(): + return None + + await ui.text("🔐 Requesting approval for transaction") + + # Stream approval widget + await ui.custom( + "approval", + { + "title": "Transaction Approval Required", + "description": "Please review and approve this transaction", + "details": { + "Type": "Wire Transfer", + "Amount": "$5,000.00", + "Recipient": "Acme Corp", + "Account": "****1234", + "Date": "2024-04-15", + }, + "approveLabel": "Approve Transfer", + "rejectLabel": "Reject Transfer", + "requireReason": True, + } + ) + + await ui.text( + "Please review the transaction details above and click approve or reject." + ) + + return "Approval form submitted" + + # Wait indefinitely + print("\n✅ Form Filler Agent running") + print(f"📍 Webhook: http://localhost:5002/webhook") + print(f"📡 Stream test: http://localhost:5002/ui/stream/test-form-1") + print(f"\nTest Form:") + print(f"curl -X POST http://localhost:5002/webhook/sync -H 'Content-Type: application/json' -d '{{\"content\": \"show form\", \"sender_id\": \"test\", \"conversation_id\": \"test-form-1\"}}'") + print(f"\nTest Approval:") + print(f"curl -X POST http://localhost:5002/webhook/sync -H 'Content-Type: application/json' -d '{{\"content\": \"approve\", \"sender_id\": \"test\", \"conversation_id\": \"test-form-2\"}}'") + print() + + try: + await asyncio.sleep(float('inf')) + except KeyboardInterrupt: + print("\n⛔ Shutting down...") + agent.stop_webhook_server() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/multi_step_workflow.py b/examples/multi_step_workflow.py new file mode 100644 index 0000000..0bca453 --- /dev/null +++ b/examples/multi_step_workflow.py @@ -0,0 +1,93 @@ +""" +Multi-step orchestration workflow: research -> implement -> verify. + +Demonstrates sequential phases where each phase uses parallel fan_out, +and later phases depend on earlier results. The coordinator synthesizes +everything into a final deliverable. +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + +from zyndai_agent import ( + ZyndAIAgent, + AgentConfig, + Coordinator, + OrchestrationContext, +) + +agent = ZyndAIAgent(AgentConfig( + name="project-manager", + description="Orchestrates multi-step projects across specialist agents", + capabilities={"skills": ["project-management", "orchestration"]}, + category="orchestration", + webhook_port=5030, + registry_url=os.getenv("REGISTRY_URL", "https://registry.zynd.ai"), +)) + +coordinator = Coordinator( + agent=agent, + max_concurrent=5, + default_timeout=120.0, + default_budget_usd=1.00, +) + + +@coordinator.strategy("build-feature") +async def build_feature(description: str, ctx: OrchestrationContext): + # Phase 1: Research — gather context in parallel + research = await ctx.fan_out([ + ("codebase-analyzer", f"Analyze existing codebase for: {description}"), + ("docs-searcher", f"Find documentation related to: {description}"), + ]) + research_summary = ctx.synthesize(research) + + # Phase 2: Implement — use synthesized briefing (not raw JSON) to guide implementation + implementation = await ctx.call_agent( + "coder", + f"Implement the following feature using this context:\n" + f"Feature: {description}\n\n" + f"{research_summary.get('briefing', str(research_summary['results']))}", + ) + + # Phase 3: Verify — fresh agent reviews the implementation + if implementation.status == "success": + verification = await ctx.call_agent( + "code-reviewer", + f"Review this implementation for correctness and security:\n" + f"{implementation.result}", + ) + else: + verification = None + + return { + "feature": description, + "research": research_summary, + "implementation": implementation.result if implementation.status == "success" else None, + "implementation_error": implementation.error if implementation.status != "success" else None, + "verification": verification.result if verification and verification.status == "success" else None, + "total_cost": ctx.budget_usd - ctx.budget_remaining, + "task_summary": ctx.task_tracker.summary(), + } + + +def handle_message(message, topic, session): + """Handle incoming project requests.""" + result = coordinator.execute_sync("build-feature", message.content) + agent.set_response(message.message_id, str(result)) + + +agent.register_handler(handle_message) + +print("\nProject manager running.") +print("Registered strategy: build-feature (research -> implement -> verify)") +print("Press Ctrl+C to stop.\n") + +try: + import time + while True: + time.sleep(1) +except KeyboardInterrupt: + print("\nShutting down.") diff --git a/examples/orchestration_demo.py b/examples/orchestration_demo.py new file mode 100644 index 0000000..dae82f5 --- /dev/null +++ b/examples/orchestration_demo.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +""" +ZyndAI Orchestration Demo — 4 Real Agents, Full Pipeline + +Shows a manager what the platform does: + - 4 independent agent services start up with cryptographic identity + - A coordinator receives a task and breaks it into subtasks + - Subtasks are dispatched to specialist agents IN PARALLEL over HTTP + - Each agent signs its messages with Ed25519 + - Results flow back, get synthesized into a final report + - Every step is tracked: timing, cost, status + +Run: + cd zyndai-agent + uv run python examples/orchestration_demo.py + + # Custom topic: + uv run python examples/orchestration_demo.py "your topic here" + +If OPENAI_API_KEY is set, agents use real GPT-4o-mini. +Otherwise, agents use built-in domain logic (no API key needed). +""" + +import asyncio +import json +import logging +import os +import sys +import textwrap +import time +import tempfile +import requests +from dotenv import load_dotenv + +load_dotenv() + +# Silence all library noise — this demo controls its own output +logging.getLogger("werkzeug").setLevel(logging.ERROR) +logging.getLogger("WebhookAgentCommunication").setLevel(logging.ERROR) +logging.getLogger("urllib3").setLevel(logging.ERROR) + +import io +import builtins +_real_print = builtins.print + +class _QuietContext: + """Suppress all stdout/stderr during agent boot — SDK is very noisy.""" + def __enter__(self): + self._stdout = sys.stdout + self._stderr = sys.stderr + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + return self + def __exit__(self, *args): + sys.stdout = self._stdout + sys.stderr = self._stderr + +print = _real_print + +from zyndai_agent.ed25519_identity import generate_keypair, save_keypair +from zyndai_agent import ( + ZyndAIAgent, + AgentConfig, + InvokeMessage, + parse_message, + sign_message, + verify_message, +) +from zyndai_agent.orchestration.task import TaskTracker +from zyndai_agent.session import AgentSession +from zyndai_agent.typed_messages import generate_id + +REGISTRY_URL = os.getenv("REGISTRY_URL", "https://dns01.zynd.ai") + +# Try to load OpenAI for real AI responses +_llm = None +try: + import openai + if os.getenv("OPENAI_API_KEY"): + _llm = openai.OpenAI() +except ImportError: + pass + +AI_MODE = "GPT-4o-mini" if _llm else "Built-in Logic" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Terminal UI +# ═══════════════════════════════════════════════════════════════════════════════ + +DIM = "\033[2m" +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +RED = "\033[31m" +RESET = "\033[0m" +CHECK = f"{GREEN}✓{RESET}" +CROSS = f"{RED}✗{RESET}" +ARROW = f"{CYAN}→{RESET}" +CLOCK = f"{YELLOW}⏱{RESET}" + + +def banner(text: str): + w = 70 + print() + print(f"{BOLD}{'═' * w}{RESET}") + print(f"{BOLD} {text}{RESET}") + print(f"{BOLD}{'═' * w}{RESET}") + + +def section(text: str): + print(f"\n{BOLD} ── {text} {'─' * max(0, 60 - len(text))}{RESET}\n") + + +def status(icon: str, label: str, detail: str = ""): + print(f" {icon} {label}{DIM} {detail}{RESET}" if detail else f" {icon} {label}") + + +def kvline(key: str, val: str): + print(f" {DIM}{key:18s}{RESET} {val}") + + +def wrap_text(text: str, width: int = 64, indent: str = " "): + for line in textwrap.wrap(text, width): + print(f"{indent}{line}") + + +def fmt_ms(ms: float) -> str: + if ms < 1000: + return f"{ms:.0f}ms" + return f"{ms / 1000:.1f}s" + + +def progress(label: str): + sys.stdout.write(f" {DIM}⋯ {label}...{RESET}") + sys.stdout.flush() + + +def progress_done(detail: str): + sys.stdout.write(f"\r {CHECK} {detail}\n") + sys.stdout.flush() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# AI / Simulation Backend +# ═══════════════════════════════════════════════════════════════════════════════ + +def ask_llm(system_prompt: str, user_prompt: str, max_tokens: int = 300) -> str: + if _llm: + resp = _llm.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + max_tokens=max_tokens, + temperature=0.7, + ) + return resp.choices[0].message.content.strip() + return "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Agent Factory +# ═══════════════════════════════════════════════════════════════════════════════ + +def make_keypair_file(tmpdir: str, name: str) -> str: + kp = generate_keypair() + path = os.path.join(tmpdir, f"{name}.json") + save_keypair(kp, path) + return path + + +def agent_base_url(agent: ZyndAIAgent) -> str: + return agent.webhook_url.replace("/webhook", "") + + +def wait_healthy(url: str, label: str): + for _ in range(30): + try: + if requests.get(f"{url}/health", timeout=2).status_code == 200: + return + except requests.ConnectionError: + pass + time.sleep(0.2) + raise RuntimeError(f"{label} at {url} never started") + + +def create_agent(tmpdir, name, desc, skills, category, port): + agent_dir = os.path.join(tmpdir, name) + os.makedirs(agent_dir, exist_ok=True) + with _QuietContext(): + agent = ZyndAIAgent(AgentConfig( + name=name, + description=desc, + capabilities={"skills": skills}, + category=category, + summary=desc[:200], + tags=skills, + webhook_port=port, + config_dir=agent_dir, + registry_url=REGISTRY_URL, + )) + + # AgentDNS doesn't persist agent_url from registration (server bug). + # Push it via update so search results include the URL for discovery. + from zyndai_agent import dns_registry + base = agent_base_url(agent) + dns_registry.update_agent( + registry_url=REGISTRY_URL, + agent_id=agent.agent_id, + keypair=agent.keypair, + updates={"agent_url": base}, + ) + return agent + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Worker Handlers +# ═══════════════════════════════════════════════════════════════════════════════ + +def _extract_task(msg) -> str: + """Extract the task description from either typed or legacy message.""" + if msg.content: + return msg.content + if msg.metadata and msg.metadata.get("task"): + return msg.metadata["task"] + try: + raw = msg.to_dict() + typed = parse_message(raw) + if hasattr(typed, "payload") and isinstance(typed.payload, dict): + return typed.payload.get("task", "") or typed.payload.get("content", "") + except Exception: + pass + return "" + + +def researcher_handler(agent): + def handler(msg, topic, session): + task = _extract_task(msg) + + if _llm: + answer = ask_llm( + "You are a research agent. You MUST return ONLY a valid JSON object (no markdown, no ```json blocks) with these exact keys: findings (list of 3 short strings), sources_consulted (int), confidence (float 0-1). Nothing else.", + f"Research this topic and return structured findings as JSON: {task}", + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = {"findings": [answer[:200]], "sources_consulted": 5, "confidence": 0.8} + else: + time.sleep(0.8) + result = { + "findings": [ + f"The market for '{task[:40]}' is projected to reach $150B by 2028", + "Three major adoption waves identified: developer tools, enterprise APIs, consumer agents", + "x402 micropayment protocol seeing 300% YoY growth across 15+ platforms", + ], + "sources_consulted": 14, + "confidence": 0.87, + } + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +def analyst_handler(agent): + def handler(msg, topic, session): + task = _extract_task(msg) + + if _llm: + answer = ask_llm( + "You are a data analyst agent. You MUST return ONLY a valid JSON object (no markdown, no ```json blocks) with these exact keys: trends (list of 3 short strings), risk_score (float 0-1), opportunity_score (float 0-1), data_points_analyzed (int). Nothing else.", + f"Analyze trends and risks for this topic, return JSON: {task}", + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = {"trends": [answer[:200]], "risk_score": 0.3, "opportunity_score": 0.85, "data_points_analyzed": 200} + else: + time.sleep(0.6) + result = { + "trends": [ + "Shift from monolithic AI to micro-agent architectures accelerating", + "Agent-to-agent payment volume doubled in last quarter", + "Developer adoption curve following classic S-curve — currently at inflection point", + ], + "risk_score": 0.25, + "opportunity_score": 0.91, + "data_points_analyzed": 1247, + } + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +def writer_handler(agent): + def handler(msg, topic, session): + task = _extract_task(msg) + + if _llm: + answer = ask_llm( + "You are an executive report writer. You MUST return ONLY a valid JSON object (no markdown, no ```json blocks) with these exact keys: summary (string — a 4-5 sentence executive summary), word_count (int). Nothing else.", + f"Write an executive summary about this topic using the provided research and analysis. Return JSON: {task}", + max_tokens=500, + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = {"summary": answer, "word_count": len(answer.split())} + else: + time.sleep(0.5) + result = { + "summary": ( + "The agent infrastructure market is at an inflection point. " + "Three key dynamics are converging: the shift to micro-agent architectures, " + "the maturation of agent-to-agent payment protocols (x402 seeing 300% YoY growth), " + "and developer tooling that reduces time-to-production from weeks to minutes. " + "Market projections indicate a $150B total addressable market by 2028, with " + "current risk assessment at 0.25/1.0 and opportunity at 0.91/1.0. " + "The primary moat will be developer experience and network density — " + "whoever gets agents talking to each other first, wins." + ), + "word_count": 89, + } + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Orchestration Engine +# ═══════════════════════════════════════════════════════════════════════════════ + +async def call_worker( + coordinator: ZyndAIAgent, + session: AgentSession, + tracker: TaskTracker, + name: str, + url: str, + task_desc: str, + cost_usd: float = 0.001, +) -> dict: + """Send a signed InvokeMessage to a worker and wait for response.""" + msg = InvokeMessage( + conversation_id=session.conversation_id, + sender_id=coordinator.agent_id, + sender_public_key=coordinator.keypair.public_key_string, + capability=name, + payload={"task": task_desc, "content": task_desc}, + timeout_seconds=30, + ) + msg.signature = sign_message(msg, coordinator.keypair.private_key) + + task = tracker.create_task(description=f"[{name}] {task_desc[:60]}...", assigned_to=url) + task.mark_running() + + try: + resp = await asyncio.to_thread( + coordinator.x402_processor.session.post, + f"{url}/webhook/sync", + json=msg.model_dump(mode="json"), + timeout=30, + ) + data = resp.json() + if resp.status_code == 200 and data.get("status") == "success": + raw = data.get("response", "{}") + parsed = json.loads(raw) if isinstance(raw, str) else raw + task.mark_completed(parsed, {"cost_usd": cost_usd, "duration_ms": task.duration_ms}) + return {"status": "success", "agent": name, "result": parsed, "duration_ms": task.duration_ms} + else: + err = data.get("error", f"HTTP {resp.status_code}") + task.mark_failed(err) + return {"status": "error", "agent": name, "error": err} + except Exception as e: + task.mark_failed(str(e)) + return {"status": "error", "agent": name, "error": str(e)} + + +async def run_pipeline(coordinator, worker_urls, topic): + session = AgentSession(conversation_id=generate_id()) + tracker = TaskTracker() + t0 = time.time() + + # ─── Phase 1: Parallel fan-out to researcher + analyst ──────────────── + + section("PHASE 1 · Research + Analysis (parallel fan-out)") + + progress("Dispatching to researcher and analyst simultaneously") + p1_start = time.time() + + # Suppress "Incoming Message:" prints from worker Flask threads + import builtins + _orig_print = builtins.print + def _quiet_print(*a, **kw): + text = " ".join(str(x) for x in a) + if "Incoming Message" in text: + return + _orig_print(*a, **kw) + builtins.print = _quiet_print + + researcher_result, analyst_result = await asyncio.gather( + call_worker(coordinator, session, tracker, "researcher", + worker_urls["researcher"], + f"Research the topic: {topic}"), + call_worker(coordinator, session, tracker, "analyst", + worker_urls["analyst"], + f"Analyze market trends and data for: {topic}"), + ) + + p1_ms = (time.time() - p1_start) * 1000 + progress_done(f"Both agents responded in {fmt_ms(p1_ms)} (parallel)\n") + + for r in [researcher_result, analyst_result]: + icon = CHECK if r["status"] == "success" else CROSS + dur = fmt_ms(r.get("duration_ms", 0)) + status(icon, f'{r["agent"]:12s}', f"{dur}") + if r["status"] == "success": + result = r["result"] + if "findings" in result: + for f in result["findings"]: + print(f" {DIM}• {f}{RESET}") + if "trends" in result: + for t in result["trends"]: + print(f" {DIM}• {t}{RESET}") + extras = [] + if "sources_consulted" in result: + extras.append(f"{result['sources_consulted']} sources") + if "confidence" in result: + extras.append(f"{result['confidence']:.0%} confidence") + if "data_points_analyzed" in result: + extras.append(f"{result['data_points_analyzed']} data points") + if "risk_score" in result: + extras.append(f"risk {result['risk_score']:.2f}") + if "opportunity_score" in result: + extras.append(f"opportunity {result['opportunity_score']:.2f}") + if extras: + print(f" {DIM} ({', '.join(extras)}){RESET}") + print() + + # ─── Phase 2: Sequential — writer synthesizes ───────────────────────── + + section("PHASE 2 · Report Writing (sequential, uses Phase 1 output)") + + research_data = researcher_result.get("result", {}) if researcher_result["status"] == "success" else {} + analysis_data = analyst_result.get("result", {}) if analyst_result["status"] == "success" else {} + + # Synthesize research + analysis into a human-readable briefing + # instead of dumping raw JSON to the writer agent. + from zyndai_agent.orchestration.coordinator import _format_result_for_briefing + + briefing_parts = [] + if research_data: + briefing_parts.append(f"[Research]\n{_format_result_for_briefing(research_data)}") + if analysis_data: + briefing_parts.append(f"[Analysis]\n{_format_result_for_briefing(analysis_data)}") + synthesized_briefing = "\n\n".join(briefing_parts) or "No data available." + + progress("Sending synthesized briefing to writer agent") + p2_start = time.time() + + writer_result = await call_worker( + coordinator, session, tracker, "writer", + worker_urls["writer"], + f"Write an executive summary about '{topic}'. " + f"Here is the synthesized research and analysis:\n\n{synthesized_briefing}", + cost_usd=0.002, + ) + + p2_ms = (time.time() - p2_start) * 1000 + icon = CHECK if writer_result["status"] == "success" else CROSS + progress_done(f"Writer responded in {fmt_ms(p2_ms)}\n") + status(icon, "writer", fmt_ms(writer_result.get("duration_ms", 0))) + + builtins.print = _orig_print + + total_ms = (time.time() - t0) * 1000 + summary_obj = tracker.summary() + + return { + "researcher": researcher_result, + "analyst": analyst_result, + "writer": writer_result, + "p1_ms": p1_ms, + "p2_ms": p2_ms, + "total_ms": total_ms, + "task_summary": summary_obj, + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════════════════ + +def main(): + topic = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "AI agents that discover and pay each other using micropayments" + + banner("ZyndAI Orchestration Demo") + print() + kvline("Topic:", f'"{topic}"') + kvline("AI Backend:", AI_MODE) + kvline("Agents:", "4 (1 coordinator + 3 specialist workers)") + kvline("Protocol:", "HTTP webhooks + Ed25519 signatures + x402") + kvline("Payment:", "x402 micropayments on Base Sepolia") + + tmpdir = tempfile.mkdtemp(prefix="zyndai_demo_") + + # ─── Boot agents ────────────────────────────────────────────────────── + + section("STARTING AGENTS") + + agents = {} + agent_defs = [ + ("coordinator", "Orchestrates multi-agent pipelines", ["orchestration"], "orchestration", 7200), + ("researcher", "Finds information and key facts", ["research", "web-search"], "research", 7201), + ("analyst", "Analyzes trends and identifies risks", ["analysis", "data"], "analysis", 7202), + ("writer", "Writes executive summaries", ["writing", "summarization"],"content", 7203), + ] + + for name, desc, skills, cat, port in agent_defs: + agents[name] = create_agent(tmpdir, name, desc, skills, cat, port) + + # Register handlers + agents["researcher"].register_handler(researcher_handler(agents["researcher"])) + agents["analyst"].register_handler(analyst_handler(agents["analyst"])) + agents["writer"].register_handler(writer_handler(agents["writer"])) + + # Wait for health + for name, agent in agents.items(): + url = agent_base_url(agent) + wait_healthy(url, name) + aid = agent.agent_id[:16] + pk = agent.keypair.public_key_b64[:12] if agent.keypair else "none" + status(CHECK, f"{name:14s} {CYAN}{url:28s}{RESET} {DIM}id={aid}… key={pk}…{RESET}") + + # Discover workers via registry search (the real agent discovery flow) + section("DISCOVERING AGENTS VIA REGISTRY") + + from zyndai_agent import dns_registry + + worker_urls = {} + coordinator = agents["coordinator"] + for worker_name, search_keyword in [("researcher", "research"), ("analyst", "analysis"), ("writer", "writing")]: + try: + found = coordinator.search_agents(keyword=search_keyword, limit=5) + match = next((a for a in found if a.get("name") == worker_name), None) + if match: + # Search results may not include agent_url — fetch full record + agent_id = match["agent_id"] + full_record = dns_registry.get_agent(REGISTRY_URL, agent_id) + url = full_record.get("agent_url", "") if full_record else "" + if url: + worker_urls[worker_name] = url + score = match.get("score", 0) + status(CHECK, f'search("{search_keyword}") {ARROW} "{match["name"]}" at {url} {DIM}score={score:.2f}{RESET}') + else: + worker_urls[worker_name] = agent_base_url(agents[worker_name]) + status(CHECK, f'search("{search_keyword}") {ARROW} "{match["name"]}" found (resolving URL locally)') + else: + worker_urls[worker_name] = agent_base_url(agents[worker_name]) + status(CLOCK, f'search("{search_keyword}") {ARROW} not in results yet, using direct URL') + except Exception as e: + worker_urls[worker_name] = agent_base_url(agents[worker_name]) + status(CLOCK, f'search("{search_keyword}") {ARROW} fallback ({e})') + + # ─── Run pipeline ───────────────────────────────────────────────────── + + results = asyncio.run(run_pipeline(agents["coordinator"], worker_urls, topic)) + + # ─── Executive Summary ──────────────────────────────────────────────── + + section("EXECUTIVE SUMMARY") + + if results["writer"]["status"] == "success": + summary_text = results["writer"]["result"].get("summary", "No summary generated.") + wrap_text(summary_text, width=64, indent=" ") + else: + print(f" {CROSS} Writer failed: {results['writer'].get('error')}") + + # ─── Pipeline Metrics ───────────────────────────────────────────────── + + section("PIPELINE METRICS") + + ts = results["task_summary"] + kvline("Total tasks:", str(ts["total"])) + kvline("Completed:", str(ts["by_status"].get("completed", 0))) + kvline("Failed:", str(ts["by_status"].get("failed", 0))) + kvline("Total cost:", f'${ts["total_cost_usd"]:.4f} USDC') + print() + kvline("Phase 1 (parallel):", fmt_ms(results["p1_ms"])) + kvline("Phase 2 (sequential):", fmt_ms(results["p2_ms"])) + kvline("Total pipeline:", f'{fmt_ms(results["total_ms"])}') + + seq_time = (results["researcher"].get("duration_ms", 0) or 0) + \ + (results["analyst"].get("duration_ms", 0) or 0) + \ + (results["writer"].get("duration_ms", 0) or 0) + if seq_time > 0: + speedup = seq_time / results["total_ms"] + kvline("Parallelism gain:", f'{speedup:.1f}x faster than sequential') + + # ─── Signature Verification Proof ───────────────────────────────────── + + section("SECURITY · Ed25519 Signature Verification") + + coord = agents["coordinator"] + msg = InvokeMessage( + sender_id=coord.agent_id, + sender_public_key=coord.keypair.public_key_string, + capability="demo", + payload={"proof": "this message is signed"}, + ) + msg.signature = sign_message(msg, coord.keypair.private_key) + verified = verify_message(msg, coord.keypair.public_key_b64) + status(CHECK if verified else CROSS, f"Coordinator signature verified: {verified}") + + tampered = InvokeMessage(**msg.model_dump()) + tampered.payload = {"proof": "TAMPERED"} + tampered_result = verify_message(tampered, coord.keypair.public_key_b64) + status(CHECK if not tampered_result else CROSS, f"Tampered message rejected: {not tampered_result}") + + # ─── Session State ──────────────────────────────────────────────────── + + section("SESSION STATE · Per-Agent Conversation Tracking") + + for name, agent in agents.items(): + sessions = agent.active_sessions + total_msgs = sum(len(s.messages) for s in sessions) + status("📋", f"{name:14s} {len(sessions)} session(s), {total_msgs} message(s)") + + # ─── Done ───────────────────────────────────────────────────────────── + + print() + for agent in agents.values(): + agent.stop_heartbeat() + banner("Demo Complete") + print(f"\n Run with a custom topic:") + print(f' {DIM}uv run python examples/orchestration_demo.py "your topic here"{RESET}\n') + + +if __name__ == "__main__": + main() diff --git a/examples/orchestration_demo_verbose.py b/examples/orchestration_demo_verbose.py new file mode 100644 index 0000000..a0c715d --- /dev/null +++ b/examples/orchestration_demo_verbose.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +""" +ZyndAI Orchestration Demo — Verbose Mode (for recording) + +Shows real-time logs of every step: agent boot, message signing, +HTTP dispatch, parallel fan-out, result synthesis — like watching +Claude Code work. + +Record with: + brew install asciinema + asciinema rec demo.cast -c "uv run python examples/orchestration_demo_verbose.py" + # Upload: asciinema upload demo.cast + +Or screen record: + uv run python examples/orchestration_demo_verbose.py +""" + +import asyncio +import json +import logging +import os +import sys +import time +import tempfile +import requests +from datetime import datetime +from dotenv import load_dotenv + +load_dotenv() + +# Silence all library noise +logging.getLogger("werkzeug").setLevel(logging.ERROR) +logging.getLogger("WebhookAgentCommunication").setLevel(logging.ERROR) +logging.getLogger("urllib3").setLevel(logging.ERROR) + +import io +import builtins + +_real_print = builtins.print + + +class _QuietContext: + def __enter__(self): + self._stdout, self._stderr = sys.stdout, sys.stderr + sys.stdout, sys.stderr = io.StringIO(), io.StringIO() + return self + def __exit__(self, *a): + sys.stdout, sys.stderr = self._stdout, self._stderr + + +from zyndai_agent.ed25519_identity import generate_keypair, save_keypair +from zyndai_agent import ( + ZyndAIAgent, AgentConfig, InvokeMessage, + parse_message, sign_message, verify_message, dns_registry, +) +from zyndai_agent.orchestration.task import TaskTracker +from zyndai_agent.orchestration.coordinator import _format_result_for_briefing +from zyndai_agent.session import AgentSession +from zyndai_agent.typed_messages import generate_id + +REGISTRY_URL = os.getenv("REGISTRY_URL", "https://dns01.zynd.ai") +TOPIC = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "AI agents that discover and pay each other using micropayments" + +_llm = None +try: + import openai + if os.getenv("OPENAI_API_KEY"): + _llm = openai.OpenAI() +except ImportError: + pass + +AI_MODE = "GPT-4o-mini" if _llm else "Built-in Logic" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Live Logger — the core of the verbose demo +# ═══════════════════════════════════════════════════════════════════════════════ + +DIM = "\033[2m" +BOLD = "\033[1m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +RED = "\033[31m" +MAGENTA= "\033[35m" +RESET = "\033[0m" +BG_DARK = "\033[48;5;236m" + +AGENT_COLORS = { + "SYSTEM": f"{BOLD}{CYAN}", + "COORDINATOR": f"{BOLD}{MAGENTA}", + "RESEARCHER": f"{BOLD}{GREEN}", + "ANALYST": f"{BOLD}{YELLOW}", + "WRITER": f"{BOLD}{CYAN}", +} + +_t0 = time.time() + + +def log(agent: str, msg: str, detail: str = "", indent: bool = False): + """Print a timestamped log line like Claude Code's output.""" + elapsed = time.time() - _t0 + ts = f"{elapsed:6.1f}s" + color = AGENT_COLORS.get(agent.upper(), DIM) + prefix = f" {DIM}{ts}{RESET} {color}{agent:14s}{RESET}" + if indent: + prefix = f" {DIM}{' ':6s}{RESET} {' ':14s}" + _real_print(f"{prefix} {msg}") + if detail: + for line in detail.split("\n"): + _real_print(f" {DIM}{' ':6s}{RESET} {' ':14s} {DIM}{line}{RESET}") + time.sleep(0.05) # Slight delay so the video shows lines appearing + + +def log_divider(label: str = ""): + _real_print() + if label: + _real_print(f" {BOLD}{'─' * 3} {label} {'─' * max(0, 55 - len(label))}{RESET}") + else: + _real_print(f" {DIM}{'─' * 64}{RESET}") + _real_print() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# LLM Backend +# ═══════════════════════════════════════════════════════════════════════════════ + +def ask_llm(agent_name: str, system: str, prompt: str, max_tokens: int = 300) -> str: + if _llm: + log(agent_name, f"Calling {AI_MODE}...") + resp = _llm.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "system", "content": system}, {"role": "user", "content": prompt}], + max_tokens=max_tokens, temperature=0.7, + ) + answer = resp.choices[0].message.content.strip() + tokens = resp.usage.total_tokens if resp.usage else 0 + log(agent_name, f"LLM responded ({tokens} tokens)") + return answer + return "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Agent Factory +# ═══════════════════════════════════════════════════════════════════════════════ + +def make_kp(tmpdir, name): + kp = generate_keypair() + p = os.path.join(tmpdir, f"{name}.json") + save_keypair(kp, p) + return p + + +def base_url(agent): + return agent.webhook_url.replace("/webhook", "") + + +def boot_agent(tmpdir, name, desc, skills, category, port): + log("SYSTEM", f"Booting {name}...", f"port={port} skills={skills}") + agent_dir = os.path.join(tmpdir, name) + os.makedirs(agent_dir, exist_ok=True) + with _QuietContext(): + agent = ZyndAIAgent(AgentConfig( + name=name, description=desc, + capabilities={"skills": skills}, + category=category, summary=desc[:200], tags=skills, + webhook_port=port, config_dir=agent_dir, + registry_url=REGISTRY_URL, + )) + + url = base_url(agent) + log("SYSTEM", f"{GREEN}✓{RESET} {name} online at {CYAN}{url}{RESET}", + f"id={agent.agent_id} key={agent.keypair.public_key_b64[:16]}...") + + # Update agent_url on registry + dns_registry.update_agent(REGISTRY_URL, agent.agent_id, agent.keypair, {"agent_url": url}) + + return agent + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Worker Handlers (with verbose logging) +# ═══════════════════════════════════════════════════════════════════════════════ + +def _extract(msg): + try: + t = parse_message(msg.to_dict()) + if hasattr(t, "payload") and isinstance(t.payload, dict): + return t.payload.get("content") or t.payload.get("task") or msg.content or "" + except Exception: + pass + return msg.content or "" + + +def make_handler(agent, agent_label, system_prompt, fallback_result): + def handler(msg, topic, session): + task = _extract(msg) + log(agent_label, f"Received message from {msg.sender_id[:20]}...") + log(agent_label, f"Task: \"{task[:80]}{'...' if len(task) > 80 else ''}\"") + + if _llm: + answer = ask_llm( + agent_label, system_prompt, + f"Return ONLY valid JSON (no markdown). {task}", + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = fallback_result + else: + log(agent_label, "Processing with built-in logic...") + delay = 0.5 + (hash(agent_label) % 5) / 10 + time.sleep(delay) + result = fallback_result + + keys = list(result.keys()) + log(agent_label, f"{GREEN}✓{RESET} Done — returning {keys}") + agent.set_response(msg.message_id, json.dumps(result)) + + return handler + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Orchestration with live logging +# ═══════════════════════════════════════════════════════════════════════════════ + +async def call_worker_verbose(coordinator, session, tracker, name, url, task_desc, cost=0.001): + label = name.upper() + + log("COORDINATOR", f"Building InvokeMessage for {label}") + msg = InvokeMessage( + conversation_id=session.conversation_id, + sender_id=coordinator.agent_id, + sender_public_key=coordinator.keypair.public_key_string, + capability=name, + payload={"task": task_desc, "content": task_desc}, + timeout_seconds=30, + ) + + log("COORDINATOR", f"Signing message with Ed25519...") + msg.signature = sign_message(msg, coordinator.keypair.private_key) + sig_preview = msg.signature[:30] + log("COORDINATOR", f"Signature: {DIM}{sig_preview}...{RESET}") + + log("COORDINATOR", f"POST {CYAN}{url}/webhook/sync{RESET} → {label}") + + task = tracker.create_task(description=f"[{name}] {task_desc[:50]}...", assigned_to=url) + task.mark_running() + + try: + resp = await asyncio.to_thread( + coordinator.x402_processor.session.post, + f"{url}/webhook/sync", + json=msg.model_dump(mode="json"), + timeout=30, + ) + data = resp.json() + if resp.status_code == 200 and data.get("status") == "success": + raw = data.get("response", "{}") + parsed = json.loads(raw) if isinstance(raw, str) else raw + task.mark_completed(parsed, {"cost_usd": cost, "duration_ms": task.duration_ms}) + log("COORDINATOR", f"{GREEN}✓{RESET} {label} responded in {task.duration_ms:.0f}ms") + return {"status": "success", "agent": name, "result": parsed, "duration_ms": task.duration_ms} + else: + err = data.get("error", f"HTTP {resp.status_code}") + task.mark_failed(err) + log("COORDINATOR", f"{RED}✗{RESET} {label} failed: {err}") + return {"status": "error", "agent": name, "error": err} + except Exception as e: + task.mark_failed(str(e)) + log("COORDINATOR", f"{RED}✗{RESET} {label} error: {e}") + return {"status": "error", "agent": name, "error": str(e)} + + +async def run_pipeline(coordinator, worker_urls, topic): + session = AgentSession(conversation_id=generate_id()) + tracker = TaskTracker() + + log_divider("PHASE 1: PARALLEL RESEARCH + ANALYSIS") + + log("COORDINATOR", f"Received task: \"{topic}\"") + log("COORDINATOR", "Breaking into 2 parallel subtasks...") + log("COORDINATOR", f" → RESEARCHER: \"Research the topic: {topic[:50]}...\"") + log("COORDINATOR", f" → ANALYST: \"Analyze trends for: {topic[:50]}...\"") + log("COORDINATOR", f"Dispatching both via asyncio.gather (parallel fan-out)...") + + # Suppress "Incoming Message" prints from Flask threads + _orig = builtins.print + builtins.print = lambda *a, **k: None if "Incoming" in " ".join(str(x) for x in a) else _orig(*a, **k) + + p1_start = time.time() + + researcher_result, analyst_result = await asyncio.gather( + call_worker_verbose(coordinator, session, tracker, "researcher", + worker_urls["researcher"], f"Research the topic: {topic}"), + call_worker_verbose(coordinator, session, tracker, "analyst", + worker_urls["analyst"], f"Analyze market trends and data for: {topic}"), + ) + + p1_ms = (time.time() - p1_start) * 1000 + log("COORDINATOR", f"Phase 1 complete: both agents responded in {p1_ms:.0f}ms (parallel)") + + # Show results + for r in [researcher_result, analyst_result]: + if r["status"] == "success": + result = r["result"] + log("COORDINATOR", f"Reading {r['agent'].upper()} results:") + for key, val in result.items(): + if isinstance(val, list): + for item in val: + log("COORDINATOR", f" • {item}", indent=True) + else: + log("COORDINATOR", f" {key}: {val}", indent=True) + + log_divider("PHASE 2: SYNTHESIS + REPORT WRITING") + + log("COORDINATOR", "Synthesizing Phase 1 results into briefing...") + + research_data = researcher_result.get("result", {}) if researcher_result["status"] == "success" else {} + analysis_data = analyst_result.get("result", {}) if analyst_result["status"] == "success" else {} + + briefing_parts = [] + if research_data: + briefing_parts.append(f"[Research]\n{_format_result_for_briefing(research_data)}") + if analysis_data: + briefing_parts.append(f"[Analysis]\n{_format_result_for_briefing(analysis_data)}") + briefing = "\n\n".join(briefing_parts) or "No data." + + log("COORDINATOR", f"Briefing ready ({len(briefing)} chars)") + for line in briefing.split("\n")[:6]: + log("COORDINATOR", f" {DIM}{line}{RESET}", indent=True) + if briefing.count("\n") > 6: + log("COORDINATOR", f" {DIM}... ({briefing.count(chr(10)) - 6} more lines){RESET}", indent=True) + + log("COORDINATOR", f"Sending briefing to WRITER for final summary...") + + p2_start = time.time() + writer_result = await call_worker_verbose( + coordinator, session, tracker, "writer", + worker_urls["writer"], + f"Write an executive summary about '{topic}'. " + f"Here is the synthesized research and analysis:\n\n{briefing}", + cost=0.002, + ) + p2_ms = (time.time() - p2_start) * 1000 + + builtins.print = _orig + + total_ms = (time.time() - (p1_start - 0.001)) * 1000 + + return { + "researcher": researcher_result, + "analyst": analyst_result, + "writer": writer_result, + "p1_ms": p1_ms, "p2_ms": p2_ms, "total_ms": total_ms, + "task_summary": tracker.summary(), + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Main +# ═══════════════════════════════════════════════════════════════════════════════ + +def main(): + global _t0 + _t0 = time.time() + + _real_print() + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print(f" {BOLD} ZyndAI Multi-Agent Orchestration{RESET}") + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print() + + log("SYSTEM", f"Topic: \"{topic}\"" if (topic := TOPIC) else "") + log("SYSTEM", f"AI Backend: {AI_MODE}") + log("SYSTEM", f"Registry: {REGISTRY_URL}") + log("SYSTEM", f"Protocol: Ed25519 signed messages over HTTP webhooks") + + tmpdir = tempfile.mkdtemp(prefix="zyndai_demo_") + + log_divider("BOOTING 4 AGENTS") + + agents = {} + defs = [ + ("coordinator", "Orchestrates multi-agent pipelines", ["orchestration"], "orchestration", 7200), + ("researcher", "Finds information and key facts", ["research", "web-search"], "research", 7201), + ("analyst", "Analyzes trends and identifies risks", ["analysis", "data"], "analysis", 7202), + ("writer", "Writes executive summaries", ["writing", "summarization"],"content", 7203), + ] + for name, desc, skills, cat, port in defs: + agents[name] = boot_agent(tmpdir, name, desc, skills, cat, port) + + # Register handlers + agents["researcher"].register_handler(make_handler( + agents["researcher"], "RESEARCHER", + "You are a research agent. Return ONLY valid JSON with: findings (list of 3 strings), sources_consulted (int), confidence (float 0-1).", + {"findings": ["Market projected at $150B by 2028", "Three adoption waves: dev tools, enterprise APIs, consumer agents", "x402 protocol seeing 300% YoY growth"], "sources_consulted": 14, "confidence": 0.87}, + )) + agents["analyst"].register_handler(make_handler( + agents["analyst"], "ANALYST", + "You are a data analyst. Return ONLY valid JSON with: trends (list of 3 strings), risk_score (float 0-1), opportunity_score (float 0-1), data_points_analyzed (int).", + {"trends": ["Shift to micro-agent architectures accelerating", "Agent-to-agent payment volume doubled", "Developer adoption at S-curve inflection"], "risk_score": 0.25, "opportunity_score": 0.91, "data_points_analyzed": 1247}, + )) + agents["writer"].register_handler(make_handler( + agents["writer"], "WRITER", + "You are an executive report writer. Return ONLY valid JSON with: summary (string, 4-5 sentences), word_count (int).", + {"summary": "The agent infrastructure market is at an inflection point. Three dynamics converge: micro-agent architectures, maturing payment protocols (x402 at 300% YoY), and developer tooling reducing time-to-production to minutes. Market projections indicate $150B TAM by 2028, with risk at 0.25 and opportunity at 0.91. The moat is developer experience and network density — whoever gets agents talking to each other first, wins.", "word_count": 62}, + )) + + # Wait for health + for name, agent in agents.items(): + url = base_url(agent) + for _ in range(30): + try: + if requests.get(f"{url}/health", timeout=2).status_code == 200: + break + except requests.ConnectionError: + pass + time.sleep(0.2) + + log_divider("AGENT DISCOVERY VIA REGISTRY") + + log("COORDINATOR", f"Searching registry at {CYAN}{REGISTRY_URL}{RESET}...") + + worker_urls = {} + coordinator = agents["coordinator"] + for worker_name, keyword in [("researcher", "research"), ("analyst", "analysis"), ("writer", "writing")]: + try: + found = coordinator.search_agents(keyword=keyword, limit=5) + match = next((a for a in found if a.get("name") == worker_name), None) + if match: + agent_id = match["agent_id"] + full = dns_registry.get_agent(REGISTRY_URL, agent_id) + url = (full or {}).get("agent_url", "") or base_url(agents[worker_name]) + worker_urls[worker_name] = url + score = match.get("score", 0) + log("COORDINATOR", f'{GREEN}✓{RESET} search("{keyword}") → {BOLD}{match["name"]}{RESET} score={score:.2f} url={url}') + else: + worker_urls[worker_name] = base_url(agents[worker_name]) + log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → resolving locally') + except Exception as e: + worker_urls[worker_name] = base_url(agents[worker_name]) + log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → fallback') + + # Run orchestration + results = asyncio.run(run_pipeline(coordinator, worker_urls, TOPIC)) + + # Final output + log_divider("EXECUTIVE SUMMARY") + + if results["writer"]["status"] == "success": + summary = results["writer"]["result"].get("summary", "") + import textwrap + for line in textwrap.wrap(summary, 64): + _real_print(f" {line}") + else: + _real_print(f" {RED}Writer failed: {results['writer'].get('error')}{RESET}") + + log_divider("PIPELINE REPORT") + + ts = results["task_summary"] + log("SYSTEM", f"Tasks: {ts['total']} total, {ts['by_status'].get('completed', 0)} completed, {ts['by_status'].get('failed', 0)} failed") + log("SYSTEM", f"Cost: ${ts['total_cost_usd']:.4f} USDC") + log("SYSTEM", f"Phase 1 (parallel): {results['p1_ms']:.0f}ms") + log("SYSTEM", f"Phase 2 (sequential): {results['p2_ms']:.0f}ms") + log("SYSTEM", f"Total: {results['total_ms']:.0f}ms") + + r_ms = results["researcher"].get("duration_ms", 0) or 0 + a_ms = results["analyst"].get("duration_ms", 0) or 0 + w_ms = results["writer"].get("duration_ms", 0) or 0 + seq = r_ms + a_ms + w_ms + if seq > 0: + log("SYSTEM", f"Parallelism: {seq / results['total_ms']:.1f}x faster than sequential") + + log_divider("SECURITY PROOF") + + msg = InvokeMessage( + sender_id=coordinator.agent_id, + sender_public_key=coordinator.keypair.public_key_string, + capability="proof", payload={"signed": True}, + ) + msg.signature = sign_message(msg, coordinator.keypair.private_key) + ok = verify_message(msg, coordinator.keypair.public_key_b64) + log("SYSTEM", f"Ed25519 signature verified: {GREEN}{'True' if ok else 'False'}{RESET}") + + tampered = InvokeMessage(**msg.model_dump()) + tampered.payload = {"signed": False} + bad = verify_message(tampered, coordinator.keypair.public_key_b64) + log("SYSTEM", f"Tampered message rejected: {GREEN}{'True' if not bad else 'False'}{RESET}") + + log_divider("SESSIONS") + + for name, agent in agents.items(): + s = agent.active_sessions + msgs = sum(len(x.messages) for x in s) + log("SYSTEM", f"{name:14s} {len(s)} session(s), {msgs} message(s)") + + _real_print() + for a in agents.values(): + a.stop_heartbeat() + + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print(f" {BOLD} Demo Complete{RESET}") + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print() + _real_print(f" {DIM}Record this demo:{RESET}") + _real_print(f" {DIM} brew install asciinema{RESET}") + _real_print(f' {DIM} asciinema rec demo.cast -c "uv run python examples/orchestration_demo_verbose.py"{RESET}') + _real_print(f" {DIM} asciinema upload demo.cast{RESET}") + _real_print() + + +if __name__ == "__main__": + main() diff --git a/examples/researcher_agent.py b/examples/researcher_agent.py new file mode 100644 index 0000000..1095317 --- /dev/null +++ b/examples/researcher_agent.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Researcher Agent with AG-UI Streaming. + +Streams live research results with citations as tool calls. +Demonstrates tool streaming and STATE updates. + +Usage: + python researcher_agent.py + +Then call it with a query - it will stream tool calls and citations. +""" + +import asyncio +import logging +import requests +from typing import Any +from zyndai_agent import ZyndAIAgent, AgentConfig + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def search_hector_rag(query: str, top_k: int = 3) -> list[dict]: + """ + Search hector-rag for relevant documents. + Falls back to mock data if service unavailable. + """ + try: + # Try to connect to hector-rag + response = requests.post( + "http://localhost:8000/search", + json={"query": query, "top_k": top_k}, + timeout=10, + ) + if response.status_code == 200: + return response.json().get("results", []) + except Exception as e: + logger.warning(f"Could not reach hector-rag: {e}") + + # Mock fallback data + return [ + { + "document": f"Mock result about '{query}' from research database #{i}", + "score": 0.95 - (i * 0.1), + "source": f"https://example.com/article-{i}", + "title": f"Understanding {query} - Part {i+1}", + } + for i in range(top_k) + ] + + +async def main(): + """Run researcher agent with AG-UI streaming.""" + + config = AgentConfig( + name="Researcher", + description="Real-time research with live citations and tool calls", + webhook_host="0.0.0.0", + webhook_port=5001, + generative_ui=True, # Enable AG-UI streaming + registry_url="http://localhost:8080", + ) + + agent = ZyndAIAgent(agent_config=config) + + @agent.register_handler + async def handle_research(message, ui): + """Handle research query and stream results.""" + + query = message.content.strip() + if not query or len(query) < 3: + await ui.text("Please provide a research query") + return "Error: Query too short" + + # Emit start + await ui.text(f"🔍 Researching: {query}") + + # Stream tool call + tool_use_id = "search-hector-1" + await ui.tool_call( + "search_hector_rag", + {"query": query, "top_k": 5}, + tool_use_id=tool_use_id, + ) + + await ui.text("Searching knowledge base...") + + # Perform search + results = await search_hector_rag(query, top_k=5) + + # Stream tool result + await ui.tool_result(tool_use_id, f"Found {len(results)} relevant sources") + + # Stream each citation as it's processed + citations = [] + for idx, result in enumerate(results, 1): + await ui.text( + f"\n**Citation {idx}**: {result.get('title', 'Untitled')}\n" + f"Score: {result.get('score', 0):.2%}\n" + f"Source: {result.get('source', 'Unknown')}" + ) + + citations.append({ + "id": idx, + "title": result.get("title", "Untitled"), + "source": result.get("source", ""), + "score": result.get("score", 0), + "document": result.get("document", ""), + }) + + # Small delay to simulate streaming + await asyncio.sleep(0.3) + + # Stream final state + await ui.state_snapshot({ + "query": query, + "citations_count": len(citations), + "citations": citations, + "status": "complete", + }) + + await ui.text( + f"\n✅ Research complete: Found {len(citations)} relevant sources" + ) + + return f"Research results for '{query}' with {len(citations)} citations" + + # Wait indefinitely + print("\n✅ Researcher Agent running") + print(f"📍 Webhook: http://localhost:5001/webhook") + print(f"📡 Stream test: http://localhost:5001/ui/stream/test-research-1") + print(f"Try: curl -X POST http://localhost:5001/webhook/sync -H 'Content-Type: application/json' -d '{{\"content\": \"quantum computing\", \"sender_id\": \"test\", \"conversation_id\": \"test-research-1\"}}'\n") + + try: + await asyncio.sleep(float('inf')) + except KeyboardInterrupt: + print("\n⛔ Shutting down...") + agent.stop_webhook_server() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/simple_typed_agent.py b/examples/simple_typed_agent.py new file mode 100644 index 0000000..f4a07ee --- /dev/null +++ b/examples/simple_typed_agent.py @@ -0,0 +1,54 @@ +""" +Simple agent using the typed message protocol. + +Handles InvokeMessage requests and returns InvokeResponse with structured +results. Demonstrates the migration path from legacy string handlers to +typed message handlers with session support. +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + +from zyndai_agent import ZyndAIAgent, AgentConfig +from zyndai_agent.typed_messages import InvokeMessage, InvokeResponse, parse_message + +agent = ZyndAIAgent(AgentConfig( + name="echo-agent", + description="Echoes back the received message with metadata", + capabilities={"skills": ["echo", "ping"]}, + webhook_port=5010, + registry_url=os.getenv("REGISTRY_URL", "https://registry.zynd.ai"), +)) + + +def handle_message(message, topic, session): + """3-arg handler: receives session automatically.""" + try: + typed = parse_message(message.to_dict()) + except Exception: + typed = None + + if isinstance(typed, InvokeMessage): + result = { + "echoed": typed.payload, + "capability_requested": typed.capability, + "session_messages": len(session.messages) if session else 0, + } + agent.set_response(message.message_id, str(result)) + else: + agent.set_response(message.message_id, f"Echo: {message.content}") + + +agent.register_handler(handle_message) + +print("\nEcho agent running. Send messages to test typed protocol.") +print("Press Ctrl+C to stop.\n") + +try: + import time + while True: + time.sleep(1) +except KeyboardInterrupt: + print("\nShutting down.") diff --git a/examples/stock_ticker_agent.py b/examples/stock_ticker_agent.py new file mode 100644 index 0000000..8ee6590 --- /dev/null +++ b/examples/stock_ticker_agent.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Stock Ticker Agent with AG-UI Streaming. + +Streams live stock chart data via AG-UI CUSTOM event. +Demonstrates generative UI integration. + +Usage: + python stock_ticker_agent.py + +Then visit: http://localhost:5000/ui/stream/conv-123 +""" + +import asyncio +import logging +from datetime import datetime, timedelta +import yfinance as yf +from zyndai_agent import ZyndAIAgent, AgentConfig + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def fetch_stock_data(ticker: str, period: str = "1mo") -> list[dict]: + """Fetch historical stock data from Yahoo Finance.""" + try: + stock = yf.Ticker(ticker) + hist = stock.history(period=period) + + # Format for Recharts + data = [] + for date, row in hist.iterrows(): + data.append({ + "date": date.strftime("%Y-%m-%d"), + "close": round(float(row["Close"]), 2), + "open": round(float(row["Open"]), 2), + "high": round(float(row["High"]), 2), + "low": round(float(row["Low"]), 2), + "volume": int(row["Volume"]), + }) + + return data + except Exception as e: + logger.error(f"Failed to fetch stock data: {e}") + return [] + + +async def main(): + """Run stock ticker agent with AG-UI streaming.""" + + config = AgentConfig( + name="Stock Ticker", + description="Real-time stock chart streaming with live data", + webhook_host="0.0.0.0", + webhook_port=5000, + generative_ui=True, # Enable AG-UI streaming + registry_url="http://localhost:8080", + ) + + agent = ZyndAIAgent(agent_config=config) + + @agent.register_handler + async def handle_stock_query(message, ui): + """Handle stock query and stream chart.""" + + # Extract ticker from message + ticker = message.content.strip().upper() + if not ticker or len(ticker) > 10: + await ui.text("Invalid ticker symbol") + return "Error: Invalid ticker" + + # Emit status + await ui.text(f"Fetching {ticker} data...") + + # Fetch data + data = fetch_stock_data(ticker, period="3mo") + + if not data: + await ui.text(f"Could not fetch data for {ticker}") + return f"Error: No data for {ticker}" + + # Calculate metrics + prices = [d["close"] for d in data] + current_price = prices[-1] + previous_price = prices[0] + change = current_price - previous_price + change_pct = (change / previous_price * 100) if previous_price else 0 + + # Stream status + await ui.text( + f"📊 {ticker} Stock Analysis\n" + f"Current: ${current_price:.2f}\n" + f"Change: ${change:+.2f} ({change_pct:+.1f}%)\n" + f"Period: 3 months ({len(data)} trading days)" + ) + + # Stream chart as CUSTOM widget + await ui.custom( + "chart", + { + "type": "line", + "title": f"{ticker} Stock Price (3M)", + "data": data, + "dataKey": "close", + "xAxis": "date", + "yAxis": "close", + "width": 100, + "height": 400, + } + ) + + # Stream additional metrics + await ui.state_snapshot({ + "ticker": ticker, + "current_price": current_price, + "change": change, + "change_percent": change_pct, + "high": max(prices), + "low": min(prices), + "avg": sum(prices) / len(prices), + }) + + await ui.text("✅ Chart loaded successfully!") + + return f"Stock data for {ticker} streamed" + + # Wait indefinitely + print("\n✅ Stock Ticker Agent running") + print(f"📍 Webhook: http://localhost:5000/webhook") + print(f"📡 Stream test: http://localhost:5000/ui/stream/test-conv-1") + print(f"Try: curl -X POST http://localhost:5000/webhook/sync -H 'Content-Type: application/json' -d '{{\"content\": \"AAPL\", \"sender_id\": \"test\", \"conversation_id\": \"test-conv-1\"}}'\n") + + try: + await asyncio.sleep(float('inf')) + except KeyboardInterrupt: + print("\n⛔ Shutting down...") + agent.stop_webhook_server() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/worker_agent.py b/examples/worker_agent.py new file mode 100644 index 0000000..dc285a3 --- /dev/null +++ b/examples/worker_agent.py @@ -0,0 +1,48 @@ +""" +Single-capability worker agent. + +Handles translation requests via the typed message protocol. Designed to +be discovered and called by coordinator agents via fan_out. +""" + +import os +from dotenv import load_dotenv + +load_dotenv() + +from zyndai_agent import ZyndAIAgent, AgentConfig + +agent = ZyndAIAgent(AgentConfig( + name="translator", + description="Translates text between languages", + capabilities={"skills": ["translation", "language"]}, + category="language", + tags=["translation", "multilingual"], + webhook_port=5011, + registry_url=os.getenv("REGISTRY_URL", "https://registry.zynd.ai"), +)) + + +def handle_message(message, topic): + """Process translation requests.""" + text = message.content + metadata = message.metadata or {} + target_lang = metadata.get("language", "French") + + # Placeholder translation (replace with actual translation logic) + translated = f"[{target_lang}] {text}" + + agent.set_response(message.message_id, translated) + + +agent.register_handler(handle_message) + +print("\nTranslator worker running.") +print("Press Ctrl+C to stop.\n") + +try: + import time + while True: + time.sleep(1) +except KeyboardInterrupt: + print("\nShutting down.") diff --git a/pyproject.toml b/pyproject.toml index 867a452..56e9fc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,13 @@ dependencies = [ "eth-account>=0.13.7", "flask>=3.1.3", "langchain>=1.2.10", + "openai>=2.30.0", "pydantic>=2.0.0", "python-dotenv>=1.0.0", "requests>=2.31.0", "rich>=13.0.0", "x402[evm,flask,requests]>=2.1.0", + "yfinance>=1.2.0", ] [project.optional-dependencies] @@ -30,8 +32,13 @@ mqtt = [ heartbeat = [ "websockets>=14.0", ] +zyndpay = [ + "zyndpay>=0.1.0", +] dev = [ "pytest>=9.0.0", + "pytest-asyncio>=0.24.0", + "zyndpay>=0.1.0", ] [project.scripts] diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..659bec2 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "skills": { + "accessibility": { + "source": "addyosmani/web-quality-skills", + "sourceType": "github", + "computedHash": "6462f04969f59f771150e6410e6a11fa397f2934be0b51e8650f495feed1e28d" + }, + "flask-api-development": { + "source": "aj-geddes/useful-ai-prompts", + "sourceType": "github", + "computedHash": "cae3a73820685f8c41e224723f96740913535e830cd05ee7a08445e8face39d6" + }, + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67" + }, + "pydantic": { + "source": "bobmatnyc/claude-mpm-skills", + "sourceType": "github", + "computedHash": "49c65015e4af62e69fa6a0df70fc04e74378244b7c7e003a1c033b946ccf50fc" + }, + "python-executor": { + "source": "inferen-sh/skills", + "sourceType": "github", + "computedHash": "2979857a8ffb1b1a64665dc611caa4fa9669c47edbf242261b27a6b15638d08c" + }, + "python-patterns": { + "source": "affaan-m/everything-claude-code", + "sourceType": "github", + "computedHash": "16fa370e24821a211eff44d9deb129e05f4ccebfaa8c14277824fd4103635af5" + }, + "python-testing-patterns": { + "source": "wshobson/agents", + "sourceType": "github", + "computedHash": "69915bb276b6aec61fe5b283a933f96584f66d14b616ce2060d95757629c6866" + }, + "seo": { + "source": "addyosmani/web-quality-skills", + "sourceType": "github", + "computedHash": "f1fed683b76913d26fbf1aa1e008e6932f7771701fc3a79925b042236aa4681a" + } + } +} diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py new file mode 100644 index 0000000..937b9c4 --- /dev/null +++ b/tests/test_orchestration.py @@ -0,0 +1,300 @@ +"""Tests for orchestration: task state machine, fan_out, coordinator.""" + +import asyncio +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +from datetime import datetime, timezone + +from zyndai_agent.orchestration.task import Task, TaskStatus, TaskTracker +from zyndai_agent.orchestration.fan_out import fan_out, FanOutResult +from zyndai_agent.orchestration.coordinator import Coordinator, OrchestrationContext +from zyndai_agent.typed_messages import InvokeMessage + + +# --- Task State Machine --- + + +class TestTaskStatus: + def test_pending_by_default(self): + t = Task(description="test") + assert t.status == TaskStatus.PENDING + assert not t.is_terminal + + def test_mark_running(self): + t = Task(description="test") + t.mark_running() + assert t.status == TaskStatus.RUNNING + assert t.started_at is not None + assert not t.is_terminal + + def test_mark_completed(self): + t = Task(description="test") + t.mark_running() + t.mark_completed({"answer": 42}, {"cost_usd": 0.01}) + assert t.status == TaskStatus.COMPLETED + assert t.result == {"answer": 42} + assert t.usage["cost_usd"] == 0.01 + assert t.is_terminal + assert t.completed_at is not None + + def test_mark_failed(self): + t = Task(description="test") + t.mark_running() + t.mark_failed("connection refused") + assert t.status == TaskStatus.FAILED + assert t.error == "connection refused" + assert t.is_terminal + + def test_mark_cancelled(self): + t = Task(description="test") + t.mark_cancelled() + assert t.status == TaskStatus.CANCELLED + assert t.is_terminal + + def test_mark_timed_out(self): + t = Task(description="test", timeout_seconds=10.0) + t.mark_timed_out() + assert t.status == TaskStatus.TIMED_OUT + assert "10.0" in t.error + + def test_duration_ms(self): + t = Task(description="test") + assert t.duration_ms is None + t.mark_running() + import time + time.sleep(0.01) + t.mark_completed({}) + assert t.duration_ms > 0 + + +class TestTaskTracker: + def test_create_and_get(self): + tracker = TaskTracker() + t = tracker.create_task("search papers", assigned_to="agent-a") + assert tracker.get_task(t.task_id) is t + + def test_active_tasks(self): + tracker = TaskTracker() + t1 = tracker.create_task("task 1") + t2 = tracker.create_task("task 2") + t1.mark_running() + t2.mark_completed({}) + active = tracker.active_tasks() + assert len(active) == 1 + assert active[0] is t1 + + def test_completed_tasks(self): + tracker = TaskTracker() + t = tracker.create_task("task") + t.mark_completed({"done": True}) + assert len(tracker.completed_tasks()) == 1 + + def test_total_cost(self): + tracker = TaskTracker() + t1 = tracker.create_task("t1") + t2 = tracker.create_task("t2") + t1.mark_completed({}, {"cost_usd": 0.03}) + t2.mark_completed({}, {"cost_usd": 0.07}) + assert tracker.total_cost() == pytest.approx(0.10) + + def test_summary(self): + tracker = TaskTracker() + t1 = tracker.create_task("t1") + t2 = tracker.create_task("t2") + t1.mark_completed({}) + s = tracker.summary() + assert s["total"] == 2 + assert s["by_status"]["completed"] == 1 + assert s["by_status"]["pending"] == 1 + + def test_get_nonexistent(self): + tracker = TaskTracker() + assert tracker.get_task("bogus") is None + + +# --- Fan Out --- + + +class TestFanOut: + @pytest.fixture + def mock_agent(self): + agent = MagicMock() + agent.entity_id = "coordinator-1" + agent.keypair = None + agent.x402_processor.session = MagicMock() + return agent + + @pytest.mark.asyncio + async def test_no_agents_found(self, mock_agent): + mock_agent.search_agents = MagicMock(return_value=[]) + + results = await fan_out( + agent=mock_agent, + assignments=[("nonexistent-skill", "do something")], + ) + assert len(results) == 1 + assert results[0].status == "error" + assert "No agent found" in results[0].error + + @pytest.mark.asyncio + async def test_successful_call(self, mock_agent): + mock_agent.search_agents = MagicMock(return_value=[ + {"name": "translator", "agent_url": "http://localhost:5001"} + ]) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "status": "success", + "response": {"translated": "bonjour"}, + } + mock_agent.x402_processor.session.post = MagicMock(return_value=mock_response) + + results = await fan_out( + agent=mock_agent, + assignments=[("translate", "translate hello to French")], + ) + assert len(results) == 1 + assert results[0].status == "success" + assert results[0].agent_name == "translator" + assert results[0].result["translated"] == "bonjour" + + @pytest.mark.asyncio + async def test_partial_failure(self, mock_agent): + call_count = {"n": 0} + + def fake_search(keyword=None, limit=3): + call_count["n"] += 1 + return [{"name": f"agent-{keyword}", "agent_url": f"http://{keyword}.local"}] + + mock_agent.search_agents = fake_search + + def fake_post(url, json=None, timeout=None): + resp = MagicMock() + if "translate.local" in url: + resp.status_code = 200 + resp.json.return_value = {"status": "success", "response": {"ok": True}} + else: + resp.status_code = 500 + resp.json.return_value = {"status": "error", "error": "internal error"} + return resp + + mock_agent.x402_processor.session.post = fake_post + + results = await fan_out( + agent=mock_agent, + assignments=[ + ("translate", "translate hello"), + ("failing-skill", "this will fail"), + ], + ) + assert len(results) == 2 + statuses = {r.capability: r.status for r in results} + assert statuses["translate"] == "success" + assert statuses["failing-skill"] == "error" + + @pytest.mark.asyncio + async def test_http_exception(self, mock_agent): + mock_agent.search_agents = MagicMock(return_value=[ + {"name": "broken", "agent_url": "http://localhost:9999"} + ]) + mock_agent.x402_processor.session.post = MagicMock( + side_effect=ConnectionError("refused") + ) + + results = await fan_out( + agent=mock_agent, + assignments=[("broken-skill", "call broken agent")], + ) + assert results[0].status == "error" + assert "refused" in results[0].error + + +# --- Coordinator --- + + +class TestCoordinator: + @pytest.fixture + def mock_agent(self): + agent = MagicMock() + agent.entity_id = "coord-1" + agent.keypair = None + agent.x402_processor.session = MagicMock() + agent.search_agents = MagicMock(return_value=[]) + return agent + + def test_register_strategy(self, mock_agent): + coord = Coordinator(agent=mock_agent) + + @coord.strategy("test") + async def test_strategy(desc, ctx): + return {"done": True} + + assert "test" in coord._strategies + + @pytest.mark.asyncio + async def test_execute_strategy(self, mock_agent): + coord = Coordinator(agent=mock_agent) + + @coord.strategy("echo") + async def echo(desc, ctx): + return {"echo": desc} + + result = await coord.execute("echo", "hello world") + assert result == {"echo": "hello world"} + + @pytest.mark.asyncio + async def test_unknown_strategy_raises(self, mock_agent): + coord = Coordinator(agent=mock_agent) + with pytest.raises(ValueError, match="Unknown strategy"): + await coord.execute("nonexistent", "test") + + def test_execute_sync(self, mock_agent): + coord = Coordinator(agent=mock_agent) + + @coord.strategy("sync-test") + async def sync_test(desc, ctx): + return {"sync": True} + + result = coord.execute_sync("sync-test", "test") + assert result == {"sync": True} + + +class TestOrchestrationContext: + def test_synthesize_all_success(self): + ctx = OrchestrationContext(coordinator=MagicMock()) + results = [ + FanOutResult(capability="a", agent_name="agent-a", status="success", result={"x": 1}), + FanOutResult(capability="b", agent_name="agent-b", status="success", result={"y": 2}), + ] + synth = ctx.synthesize(results) + assert synth["status"] == "success" + assert len(synth["results"]) == 2 + assert len(synth["failures"]) == 0 + + def test_synthesize_partial_failure(self): + ctx = OrchestrationContext(coordinator=MagicMock()) + results = [ + FanOutResult(capability="a", agent_name="agent-a", status="success", result={"x": 1}), + FanOutResult(capability="b", agent_name="agent-b", status="error", error="timeout"), + ] + synth = ctx.synthesize(results) + assert synth["status"] == "success" + assert len(synth["results"]) == 1 + assert len(synth["failures"]) == 1 + assert synth["failures"][0]["error"] == "timeout" + + def test_synthesize_all_failures(self): + ctx = OrchestrationContext(coordinator=MagicMock()) + results = [ + FanOutResult(capability="a", status="error", error="boom"), + ] + synth = ctx.synthesize(results) + assert synth["status"] == "error" + assert len(synth["results"]) == 0 + + def test_budget_tracking(self): + ctx = OrchestrationContext(coordinator=MagicMock(), budget_usd=1.0) + assert ctx.budget_remaining == 1.0 + ctx._spent_usd = 0.3 + assert ctx.budget_remaining == pytest.approx(0.7) diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..4f6f8fc --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,107 @@ +"""Tests for agent sessions and session manager.""" + +import pytest +from datetime import datetime, timezone + +from zyndai_agent.session import AgentSession, SessionManager +from zyndai_agent.typed_messages import InvokeMessage, InvokeResponse + + +class TestAgentSession: + def test_create_session(self): + s = AgentSession(conversation_id="conv-1", participants=["agent-a"]) + assert s.conversation_id == "conv-1" + assert s.total_cost_usd == 0.0 + assert len(s.messages) == 0 + + def test_add_message(self): + s = AgentSession(conversation_id="conv-1") + msg = InvokeMessage(sender_id="a", capability="test", payload={}) + s.add_message(msg) + assert len(s.messages) == 1 + assert s.messages[0] is msg + + def test_add_message_tracks_cost(self): + s = AgentSession(conversation_id="conv-1") + msg = InvokeResponse( + sender_id="b", + in_reply_to="x", + status="success", + result={}, + usage={"cost_usd": 0.05, "tokens": 500}, + ) + s.add_message(msg) + assert s.total_cost_usd == pytest.approx(0.05) + + def test_add_message_no_usage(self): + s = AgentSession(conversation_id="conv-1") + msg = InvokeMessage(sender_id="a", capability="test", payload={}) + s.add_message(msg) + assert s.total_cost_usd == 0.0 + + def test_get_history_default(self): + s = AgentSession(conversation_id="conv-1") + for i in range(100): + s.add_message(InvokeMessage(sender_id="a", capability=f"cap-{i}", payload={})) + history = s.get_history(limit=10) + assert len(history) == 10 + assert history[0].capability == "cap-90" + + def test_to_dict_from_dict(self): + s = AgentSession(conversation_id="conv-1", participants=["a", "b"]) + msg = InvokeMessage(sender_id="a", capability="test", payload={"k": "v"}) + s.add_message(msg) + + d = s.to_dict() + assert d["conversation_id"] == "conv-1" + assert len(d["messages"]) == 1 + assert d["messages"][0]["capability"] == "test" + + restored = AgentSession.from_dict(d) + assert restored.conversation_id == "conv-1" + assert restored.participants == ["a", "b"] + + def test_updated_at_changes(self): + s = AgentSession(conversation_id="conv-1") + t0 = s.updated_at + import time + time.sleep(0.01) + s.add_message(InvokeMessage(sender_id="a", capability="x", payload={})) + assert s.updated_at > t0 + + +class TestSessionManager: + def test_get_or_create_new(self): + mgr = SessionManager() + s = mgr.get_or_create("conv-1", "agent-a") + assert s.conversation_id == "conv-1" + assert "agent-a" in s.participants + + def test_get_or_create_existing(self): + mgr = SessionManager() + s1 = mgr.get_or_create("conv-1", "agent-a") + s2 = mgr.get_or_create("conv-1", "agent-b") + assert s1 is s2 + assert "agent-a" in s1.participants + assert "agent-b" in s1.participants + + def test_get_session_missing(self): + mgr = SessionManager() + assert mgr.get_session("nonexistent") is None + + def test_active_sessions(self): + mgr = SessionManager() + mgr.get_or_create("conv-1", "a") + mgr.get_or_create("conv-2", "b") + assert len(mgr.active_sessions) == 2 + + def test_close_session(self): + mgr = SessionManager() + mgr.get_or_create("conv-1", "a") + mgr.close_session("conv-1") + assert mgr.get_session("conv-1") is None + assert len(mgr.active_sessions) == 0 + + def test_close_nonexistent_is_noop(self): + mgr = SessionManager() + mgr.close_session("nonexistent") diff --git a/tests/test_signatures.py b/tests/test_signatures.py new file mode 100644 index 0000000..8d42c8d --- /dev/null +++ b/tests/test_signatures.py @@ -0,0 +1,49 @@ +"""Tests for message signing and verification.""" + +import pytest + +from zyndai_agent.ed25519_identity import generate_keypair +from zyndai_agent.signatures import sign_message, verify_message +from zyndai_agent.typed_messages import InvokeMessage + + +@pytest.fixture +def keypair(): + return generate_keypair() + + +@pytest.fixture +def sample_message(): + return InvokeMessage( + sender_id="agent-a", + capability="translate", + payload={"text": "hello", "language": "French"}, + ) + + +class TestSignAndVerify: + def test_round_trip(self, keypair, sample_message): + sig = sign_message(sample_message, keypair.private_key) + assert sig.startswith("ed25519:") + sample_message.signature = sig + assert verify_message(sample_message, keypair.public_key_b64) is True + + def test_wrong_key_fails(self, keypair, sample_message): + other_kp = generate_keypair() + sig = sign_message(sample_message, keypair.private_key) + sample_message.signature = sig + assert verify_message(sample_message, other_kp.public_key_b64) is False + + def test_tampered_payload_fails(self, keypair, sample_message): + sig = sign_message(sample_message, keypair.private_key) + sample_message.signature = sig + sample_message.payload["text"] = "tampered" + assert verify_message(sample_message, keypair.public_key_b64) is False + + def test_empty_signature_fails(self, keypair, sample_message): + assert verify_message(sample_message, keypair.public_key_b64) is False + + def test_deterministic(self, keypair, sample_message): + sig1 = sign_message(sample_message, keypair.private_key) + sig2 = sign_message(sample_message, keypair.private_key) + assert sig1 == sig2 diff --git a/tests/test_typed_messages.py b/tests/test_typed_messages.py new file mode 100644 index 0000000..212fa8f --- /dev/null +++ b/tests/test_typed_messages.py @@ -0,0 +1,222 @@ +"""Tests for the typed message protocol.""" + +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError + +from zyndai_agent.typed_messages import ( + InvokeMessage, + InvokeResponse, + StreamChunk, + TaskAssignment, + TaskNotification, + ShutdownRequest, + ShutdownResponse, + parse_message, + typed_to_legacy, + generate_id, +) + + +class TestParseMessage: + def test_parse_invoke_message(self): + raw = { + "type": "invoke", + "message_id": "msg-1", + "conversation_id": "conv-1", + "sender_id": "agent-a", + "capability": "translate", + "payload": {"text": "hello", "language": "French"}, + } + msg = parse_message(raw) + assert isinstance(msg, InvokeMessage) + assert msg.capability == "translate" + assert msg.payload["text"] == "hello" + assert msg.max_budget_usd == 0.0 + + def test_parse_invoke_response(self): + raw = { + "type": "invoke_response", + "message_id": "msg-2", + "conversation_id": "conv-1", + "sender_id": "agent-b", + "in_reply_to": "msg-1", + "status": "success", + "result": {"translated": "bonjour"}, + } + msg = parse_message(raw) + assert isinstance(msg, InvokeResponse) + assert msg.status == "success" + assert msg.result["translated"] == "bonjour" + + def test_parse_stream_chunk(self): + raw = { + "type": "stream_chunk", + "message_id": "msg-3", + "conversation_id": "conv-1", + "sender_id": "agent-b", + "in_reply_to": "msg-1", + "chunk_index": 0, + "content": "partial result", + "is_final": False, + } + msg = parse_message(raw) + assert isinstance(msg, StreamChunk) + assert msg.chunk_index == 0 + assert not msg.is_final + + def test_parse_task_assignment(self): + raw = { + "type": "task_assignment", + "message_id": "msg-4", + "conversation_id": "conv-1", + "sender_id": "coordinator", + "task_id": "task-1", + "description": "search for papers", + "context": {"topic": "AI"}, + "constraints": {"timeout": 30}, + } + msg = parse_message(raw) + assert isinstance(msg, TaskAssignment) + assert msg.task_id == "task-1" + + def test_parse_task_notification(self): + raw = { + "type": "task_notification", + "message_id": "msg-5", + "conversation_id": "conv-1", + "sender_id": "worker-1", + "task_id": "task-1", + "in_reply_to": "msg-4", + "status": "completed", + "summary": "Found 5 papers", + "result": {"papers": 5}, + "usage": {"tokens": 1000, "cost_usd": 0.01}, + } + msg = parse_message(raw) + assert isinstance(msg, TaskNotification) + assert msg.status == "completed" + assert msg.usage["cost_usd"] == 0.01 + + def test_parse_shutdown_request(self): + raw = { + "type": "shutdown_request", + "message_id": "msg-6", + "conversation_id": "conv-1", + "sender_id": "coordinator", + "reason": "task complete", + } + msg = parse_message(raw) + assert isinstance(msg, ShutdownRequest) + assert msg.reason == "task complete" + + def test_parse_shutdown_response(self): + raw = { + "type": "shutdown_response", + "message_id": "msg-7", + "conversation_id": "conv-1", + "sender_id": "worker-1", + "in_reply_to": "msg-6", + "approved": True, + } + msg = parse_message(raw) + assert isinstance(msg, ShutdownResponse) + assert msg.approved is True + + +class TestLegacyCompat: + def test_legacy_content_message_wraps_as_invoke(self): + raw = { + "content": "translate hello to French", + "sender_id": "agent-a", + "message_type": "query", + } + msg = parse_message(raw) + assert isinstance(msg, InvokeMessage) + assert msg.capability == "legacy" + assert msg.payload["content"] == "translate hello to French" + + def test_legacy_prompt_field(self): + raw = {"prompt": "do something", "sender_id": "test"} + msg = parse_message(raw) + assert isinstance(msg, InvokeMessage) + assert msg.payload["content"] == "do something" + + def test_legacy_preserves_message_id(self): + raw = { + "content": "hello", + "sender_id": "test", + "message_id": "custom-id", + "conversation_id": "conv-99", + } + msg = parse_message(raw) + assert msg.message_id == "custom-id" + assert msg.conversation_id == "conv-99" + + +class TestInvalidMessages: + def test_missing_type_and_content_raises(self): + with pytest.raises((ValidationError, KeyError)): + parse_message({"sender_id": "test", "type": "bogus"}) + + def test_invalid_status_raises(self): + with pytest.raises(ValidationError): + parse_message({ + "type": "invoke_response", + "message_id": "x", + "conversation_id": "y", + "sender_id": "z", + "in_reply_to": "a", + "status": "bogus_status", + "result": {}, + }) + + +class TestTypedToLegacy: + def test_invoke_to_legacy(self): + msg = InvokeMessage( + sender_id="agent-a", + capability="translate", + payload={"content": "hello world"}, + ) + legacy = typed_to_legacy(msg) + assert legacy.content == "hello world" + assert legacy.sender_id == "agent-a" + assert legacy.message_type == "invoke" + + def test_response_to_legacy(self): + msg = InvokeResponse( + sender_id="agent-b", + in_reply_to="msg-1", + status="success", + result={"answer": 42}, + ) + legacy = typed_to_legacy(msg) + assert "42" in legacy.content + assert legacy.message_type == "invoke_response" + + +class TestRoundTrip: + def test_invoke_serialize_deserialize(self): + original = InvokeMessage( + sender_id="agent-a", + capability="search", + payload={"query": "test"}, + max_budget_usd=0.5, + ) + raw = original.model_dump(mode="json") + restored = parse_message(raw) + assert isinstance(restored, InvokeMessage) + assert restored.capability == original.capability + assert restored.payload == original.payload + assert restored.message_id == original.message_id + + +class TestGenerateId: + def test_unique(self): + ids = {generate_id() for _ in range(100)} + assert len(ids) == 100 + + def test_format(self): + id_ = generate_id() + assert len(id_) == 36 # UUID4 with hyphens diff --git a/uv.lock b/uv.lock index 2427db7..a4e90e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,14 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "aiohappyeyeballs" @@ -149,6 +157,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "bitarray" version = "3.8.0" @@ -465,6 +486,27 @@ wheels = [ { 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" }, ] +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + [[package]] name = "cytoolz" version = "1.1.0" @@ -582,6 +624,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/1f/0498009aa563a9c5d04f520aadc6e1c0942434d089d0b2f51ea986470f55/cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e", size = 927963, upload-time = "2025-10-19T00:44:04.85Z" }, ] +[[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 = "eth-abi" version = "5.2.0" @@ -717,6 +768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -891,6 +951,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[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/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/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 = "jsonpatch" version = "1.33" @@ -1019,6 +1147,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/79/59ecf7dceafd655ed20270a0f595d9e8e13895231cebcfbff9b6eec51fc4/langsmith-0.4.49-py3-none-any.whl", hash = "sha256:95f84edcd8e74ed658e4a3eb7355b530f35cb08a9a8865dbfde6740e4b18323c", size = 410905, upload-time = "2025-11-26T21:45:14.606Z" }, ] +[[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 = "markupsafe" version = "3.0.3" @@ -1082,6 +1222,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[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 = "multidict" version = "6.7.0" @@ -1181,6 +1330,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] +[[package]] +name = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1190,6 +1345,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "openai" +version = "2.30.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/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -1299,6 +1534,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + [[package]] name = "parsimonious" version = "0.10.0" @@ -1311,6 +1598,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, ] +[[package]] +name = "peewee" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/8e/8fe6b93914ed40b9cb5162e45e1be4f8bb8cf7f5a49333aa1a2d383e4870/peewee-4.0.4.tar.gz", hash = "sha256:70e07c14a10bec8d663514bda5854e44ef15d5b03974b41f7218066b6fd3a065", size = 718021, upload-time = "2026-04-02T13:52:25.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9b/bee274b72adc7c692bf7cb8d6b0cd4071acf2957e82dace45d3f2770470e/peewee-4.0.4-py3-none-any.whl", hash = "sha256:37ccd3f89e523c7b42eed023cd90b48d088753ddff1d74e854a9c6445e7bd797", size = 144487, upload-time = "2026-04-02T13:52:24.099Z" }, +] + +[[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" @@ -1404,6 +1709,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1566,6 +1886,31 @@ 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 = "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-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1575,6 +1920,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + [[package]] name = "pyunormalize" version = "17.0.0" @@ -1751,6 +2105,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[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 = "rlp" version = "4.1.0" @@ -1763,6 +2130,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/fb/e4c0ced9893b84ac95b7181d69a9786ce5879aeb3bbbcbba80a164f85d6a/rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f", size = 19973, upload-time = "2025-02-04T22:05:57.05Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[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 = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "tenacity" version = "9.1.2" @@ -1781,6 +2175,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, ] +[[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 = "types-requests" version = "2.32.4.20260107" @@ -1814,6 +2220,15 @@ 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 = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -2119,6 +2534,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] +[[package]] +name = "yfinance" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1b/431d0ebd6a1e9deaffc8627cc4d26fd869841f31a1429cab7443eced0766/yfinance-1.2.0.tar.gz", hash = "sha256:80cec643eb983330ca63debab1b5492334fa1e6338d82cb17dd4e7b95079cfab", size = 140501, upload-time = "2026-02-16T19:52:34.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/60/462859de757ac56830824da7e8cf314b8b0321af5853df867c84cd6c2128/yfinance-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c27d1ebfc6275f476721cc6dba035a49d0cf9a806d6aa1785c9e10cf8a610d8", size = 130247, upload-time = "2026-02-16T19:52:33.109Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" @@ -2178,28 +2616,41 @@ wheels = [ [[package]] name = "zyndai-agent" -version = "0.2.4" -source = { virtual = "." } +version = "0.3.2" +source = { editable = "." } dependencies = [ { name = "base58" }, { name = "cryptography" }, { name = "eth-account" }, { name = "flask" }, { name = "langchain" }, - { name = "paho-mqtt" }, + { name = "openai" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "requests" }, + { name = "rich" }, { name = "x402", extra = ["evm", "flask", "requests"] }, + { name = "yfinance" }, ] [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "zyndpay" }, +] +heartbeat = [ + { name = "websockets" }, +] +mqtt = [ + { name = "paho-mqtt" }, ] ngrok = [ { name = "pyngrok" }, ] +zyndpay = [ + { name = "zyndpay" }, +] [package.metadata] requires-dist = [ @@ -2208,12 +2659,31 @@ requires-dist = [ { name = "eth-account", specifier = ">=0.13.7" }, { name = "flask", specifier = ">=3.1.3" }, { name = "langchain", specifier = ">=1.2.10" }, - { name = "paho-mqtt", specifier = ">=2.1.0" }, + { name = "openai", specifier = ">=2.30.0" }, + { name = "paho-mqtt", marker = "extra == 'mqtt'", specifier = ">=2.1.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pyngrok", marker = "extra == 'ngrok'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", specifier = ">=2.31.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "websockets", marker = "extra == 'heartbeat'", specifier = ">=14.0" }, { name = "x402", extras = ["evm", "flask", "requests"], specifier = ">=2.1.0" }, + { name = "yfinance", specifier = ">=1.2.0" }, + { name = "zyndpay", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "zyndpay", marker = "extra == 'zyndpay'", specifier = ">=0.1.0" }, +] +provides-extras = ["ngrok", "mqtt", "heartbeat", "zyndpay", "dev"] + +[[package]] +name = "zyndpay" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/3f/ac80a0eb3d81ff778c784011a50cda929d470ca3ee0a11e6a12ec5501a96/zyndpay-1.5.0.tar.gz", hash = "sha256:fe0d0caed39638b07a72915372fca4d652ddbf7230a8ba7403feb27ea5c51060", size = 23058, upload-time = "2026-04-02T23:59:42.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/7b/5d5d7c922c25a2ceb3a9328eb7b73a1548a64ff4bde155bca351718d1bea/zyndpay-1.5.0-py3-none-any.whl", hash = "sha256:e44acb8cec3788df7795162c686428b0174e65d04c0695bc3cd01cc728751642", size = 18736, upload-time = "2026-04-02T23:59:41.104Z" }, ] -provides-extras = ["ngrok", "dev"] diff --git a/zynd_cli/commands/_entity_base.py b/zynd_cli/commands/_entity_base.py index 6af5801..1445caf 100644 --- a/zynd_cli/commands/_entity_base.py +++ b/zynd_cli/commands/_entity_base.py @@ -284,6 +284,9 @@ def run(self, args) -> None: console.print( f" [bold red]✗[/bold red] Registration failed: {e}" ) + console.print( + f" [dim]⚠ Entity is running locally but NOT discoverable on the network[/dim]" + ) # --- Step 5: wait for exit ----------------------------------- console.print() diff --git a/zynd_cli/commands/register.py b/zynd_cli/commands/register.py index 8c29dc9..1c7b75d 100644 --- a/zynd_cli/commands/register.py +++ b/zynd_cli/commands/register.py @@ -25,7 +25,7 @@ def register_parser(subparsers: argparse._SubParsersAction, parents=None): p = subparsers.add_parser("register", help="Register an agent on the registry", parents=parents or []) p.add_argument("--name", help="Agent display name") - p.add_argument("--agent-url", help="Agent base URL") + p.add_argument("--agent-url", dest="entity_url", help="Agent base URL") p.add_argument("--category", default="general", help="Agent category (default: general)") p.add_argument("--tags", nargs="*", help="Agent tags") p.add_argument("--summary", help="Agent summary") diff --git a/zyndai_agent/__init__.py b/zyndai_agent/__init__.py index cd3debc..b85da02 100644 --- a/zyndai_agent/__init__.py +++ b/zyndai_agent/__init__.py @@ -20,6 +20,31 @@ ) from zyndai_agent import dns_registry as DNSRegistryClient +from zyndai_agent.typed_messages import ( + TypedMessage, + MessageBase, + InvokeMessage, + InvokeResponse, + StreamChunk, + TaskAssignment, + TaskNotification, + ShutdownRequest, + ShutdownResponse, + parse_message, + typed_to_legacy, +) +from zyndai_agent.signatures import sign_message, verify_message +from zyndai_agent.session import AgentSession, SessionManager +from zyndai_agent.orchestration.task import Task, TaskStatus, TaskTracker +from zyndai_agent.orchestration.fan_out import fan_out, FanOutResult +from zyndai_agent.orchestration.coordinator import Coordinator, OrchestrationContext + +try: + from zyndpay import PaymentPolicy, PaymentRouter +except ImportError: + PaymentPolicy = None + PaymentRouter = None + __all__ = [ "ZyndBase", "ZyndBaseConfig", @@ -49,4 +74,26 @@ "resolve_card_from_config", "load_derivation_metadata", "DNSRegistryClient", + "TypedMessage", + "MessageBase", + "InvokeMessage", + "InvokeResponse", + "StreamChunk", + "TaskAssignment", + "TaskNotification", + "ShutdownRequest", + "ShutdownResponse", + "parse_message", + "typed_to_legacy", + "sign_message", + "verify_message", + "AgentSession", + "SessionManager", + "Task", + "TaskStatus", + "TaskTracker", + "fan_out", + "FanOutResult", + "Coordinator", + "OrchestrationContext", ] diff --git a/zyndai_agent/agent.py b/zyndai_agent/agent.py index 10f646f..f726277 100644 --- a/zyndai_agent/agent.py +++ b/zyndai_agent/agent.py @@ -1,12 +1,8 @@ -import asyncio import base64 -import hashlib import json import logging import os import threading -import time -import requests from zyndai_agent.search import SearchAndDiscoveryManager from zyndai_agent.identity import IdentityManager from zyndai_agent.communication import AgentCommunicationManager @@ -17,18 +13,10 @@ Ed25519Keypair, keypair_from_private_bytes, ) -from zyndai_agent.entity_card import build_entity_card, sign_entity_card from zyndai_agent.entity_card_loader import ( - load_entity_card, resolve_keypair, - build_runtime_card, - compute_card_hash, - resolve_card_from_config, - load_derivation_metadata, ) from zyndai_agent.base import ZyndBase, ZyndBaseConfig, _console, _log, _log_ok, _log_warn, _log_err, _log_heartbeat -from zyndai_agent import dns_registry -from pydantic import BaseModel from typing import Optional, Any, Callable, Union, List from enum import Enum diff --git a/zyndai_agent/base.py b/zyndai_agent/base.py index 77f0c74..8d20a8a 100644 --- a/zyndai_agent/base.py +++ b/zyndai_agent/base.py @@ -116,7 +116,7 @@ def __init__(self, config: ZyndBaseConfig): self._static_card = None # Resolve keypair - self.keypair = self._resolve_keypair(config) + self.keypair = self._resolve_keypair(config, self._entity_type) if not self.keypair: raise ValueError( "Keypair not found. Set ZYND_AGENT_KEYPAIR_PATH / ZYND_SERVICE_KEYPAIR_PATH " @@ -213,14 +213,15 @@ def _build_card(): self._display_info() @staticmethod - def _resolve_keypair(config) -> Optional[Ed25519Keypair]: + def _resolve_keypair(config, entity_type: str = "agent") -> Optional[Ed25519Keypair]: """Resolve keypair from env vars or config.keypair_path.""" - # Check service-specific env var first - env_path = os.environ.get("ZYND_SERVICE_KEYPAIR_PATH") + env_var = "ZYND_SERVICE_KEYPAIR_PATH" if entity_type == "service" else "ZYND_AGENT_KEYPAIR_PATH" + env_path = os.environ.get(env_var) + kp_config = config if env_path: - config.keypair_path = env_path + kp_config = config.model_copy(update={"keypair_path": env_path}) try: - return resolve_keypair(config) + return resolve_keypair(kp_config) except ValueError: return None diff --git a/zyndai_agent/config_manager.py b/zyndai_agent/config_manager.py index e6121c1..59484a9 100644 --- a/zyndai_agent/config_manager.py +++ b/zyndai_agent/config_manager.py @@ -47,10 +47,10 @@ def load_config(config_dir: str = None): with open(config_path, "r") as f: config = json.load(f) except json.JSONDecodeError: - print(f"Warning: {config_path} is corrupted. Creating a new agent...") + logger.warning(f"Warning: {config_path} is corrupted. Creating a new agent...") return None - print(f"Loaded agent config from {config_path}") + logger.info(f"Loaded agent config from {config_path}") return config @staticmethod @@ -63,7 +63,7 @@ def save_config(config: dict, config_dir: str = None): with open(config_path, "w") as f: json.dump(config, f, indent=2) - print(f"Saved agent config to {config_path}") + logger.info(f"Saved agent config to {config_path}") @staticmethod def _is_legacy_config(config: dict) -> bool: @@ -77,7 +77,7 @@ def _migrate_legacy_config(config: dict, agent_config) -> dict: Preserves old seed as 'legacy_seed' for x402 payment continuity. """ - print("Detected legacy v1 config. Migrating to v2 (agent-dns)...") + logger.info("Detected legacy v1 config. Migrating to v2 (agent-dns)...") # Generate new Ed25519 keypair kp = generate_keypair() @@ -99,9 +99,9 @@ def _migrate_legacy_config(config: dict, agent_config) -> dict: tags=getattr(agent_config, "tags", None), summary=getattr(agent_config, "summary", None), ) - print(f"Registered migrated agent on agent-dns: {entity_id}") + logger.info(f"Registered migrated agent on agent-dns: {entity_id}") except Exception as e: - print(f"Warning: Could not register on agent-dns during migration: {e}") + logger.warning(f"Could not register on agent-dns during migration: {e}") new_config = { "schema_version": "2.0", @@ -159,8 +159,8 @@ def create_agent(agent_config, config_dir: str = None) -> dict: summary=getattr(agent_config, "summary", None), ) except Exception as e: - print(f"Warning: Could not register on agent-dns: {e}") - print("Agent will operate with local identity only.") + logger.warning(f"Could not register on agent-dns: {e}") + logger.warning("Agent will operate with local identity only.") config = { "schema_version": "2.0", @@ -211,7 +211,7 @@ def load_or_create(agent_config): raise ValueError("capabilities is required in AgentConfig to create a new agent.") dir_name = config_dir or ConfigManager.DEFAULT_CONFIG_DIR - print(f"No {dir_name}/config.json found. Creating a new agent...") + logger.info(f"No {dir_name}/config.json found. Creating a new agent...") return ConfigManager.create_agent( agent_config=agent_config, config_dir=config_dir, diff --git a/zyndai_agent/dns_registry.py b/zyndai_agent/dns_registry.py index 1aba0e6..0b8d7e8 100644 --- a/zyndai_agent/dns_registry.py +++ b/zyndai_agent/dns_registry.py @@ -241,9 +241,6 @@ def update_entity( except requests.RequestException as e: logger.error(f"Request failed: {e}") return False - except requests.RequestException as e: - logger.error(f"Request failed: {e}") - return False def delete_entity( @@ -466,7 +463,7 @@ def get_entity_fqan(registry_url: str, entity_id: str) -> Optional[str]: data = resp.json() results = data.get("results", []) for r in results: - if r.get("entity_id") == entity_id: + if r.get("agent_id") == entity_id: fqan = r.get("fqan", "") if fqan: return fqan diff --git a/zyndai_agent/orchestration/__init__.py b/zyndai_agent/orchestration/__init__.py new file mode 100644 index 0000000..f9c0e29 --- /dev/null +++ b/zyndai_agent/orchestration/__init__.py @@ -0,0 +1,28 @@ +""" +Orchestration primitives for multi-agent coordination. + +Provides task tracking, parallel fan-out dispatch, and a strategy-based +Coordinator API for building agents that orchestrate other agents. +""" + +from zyndai_agent.orchestration.task import Task, TaskStatus, TaskTracker + +__all__ = [ + "Task", + "TaskStatus", + "TaskTracker", +] + +# Fan-out and Coordinator are imported lazily to avoid circular deps +# at module level. They are added to __all__ and importable directly: +# from zyndai_agent.orchestration import Coordinator, fan_out + + +def __getattr__(name: str): + if name in ("fan_out", "FanOutResult"): + from zyndai_agent.orchestration.fan_out import fan_out, FanOutResult + return fan_out if name == "fan_out" else FanOutResult + if name in ("Coordinator", "OrchestrationContext"): + from zyndai_agent.orchestration.coordinator import Coordinator, OrchestrationContext + return Coordinator if name == "Coordinator" else OrchestrationContext + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/zyndai_agent/orchestration/coordinator.py b/zyndai_agent/orchestration/coordinator.py new file mode 100644 index 0000000..1e98878 --- /dev/null +++ b/zyndai_agent/orchestration/coordinator.py @@ -0,0 +1,269 @@ +""" +Strategy-based orchestration coordinator. + +Coordinator lets developers define named strategies (async functions decorated +with @coordinator.strategy) that orchestrate multiple agents via fan_out, +call_agent, and synthesize patterns. + +Usage: + coordinator = Coordinator(agent=my_agent) + + @coordinator.strategy("research") + async def research(topic: str, ctx: OrchestrationContext): + results = await ctx.fan_out([ + ("web-search", f"search for {topic}"), + ("summarizer", f"summarize findings on {topic}"), + ]) + return ctx.synthesize(results) + + result = await coordinator.execute("research", "quantum computing") +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Any, Callable, Awaitable + +from zyndai_agent.orchestration.task import TaskTracker +from zyndai_agent.orchestration.fan_out import fan_out, FanOutResult +from zyndai_agent.session import AgentSession +from zyndai_agent.typed_messages import InvokeMessage, generate_id + +logger = logging.getLogger(__name__) + +StrategyFn = Callable[..., Awaitable[dict[str, Any]]] + + +class Coordinator: + """Orchestrates multiple agents via registered strategies.""" + + def __init__( + self, + agent: Any, + max_concurrent: int = 10, + default_timeout: float = 60.0, + default_budget_usd: float = 1.0, + ): + self.agent = agent + self.max_concurrent = max_concurrent + self.default_timeout = default_timeout + self.default_budget_usd = default_budget_usd + self._strategies: dict[str, StrategyFn] = {} + + def strategy(self, name: str) -> Callable[[StrategyFn], StrategyFn]: + """Decorator to register a named orchestration strategy.""" + def decorator(func: StrategyFn) -> StrategyFn: + self._strategies[name] = func + return func + return decorator + + async def execute( + self, + strategy_name: str, + task_description: str, + *, + budget_usd: float | None = None, + timeout: float | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Run a registered strategy.""" + if strategy_name not in self._strategies: + raise ValueError( + f"Unknown strategy '{strategy_name}'. " + f"Registered: {list(self._strategies.keys())}" + ) + + ctx = OrchestrationContext( + coordinator=self, + budget_usd=budget_usd or self.default_budget_usd, + timeout=timeout or self.default_timeout, + ) + + strategy_fn = self._strategies[strategy_name] + return await strategy_fn(task_description, ctx, **kwargs) + + def execute_sync( + self, + strategy_name: str, + task_description: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Synchronous wrapper around execute() for non-async contexts.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit( + asyncio.run, + self.execute(strategy_name, task_description, **kwargs), + ) + return future.result(timeout=kwargs.get("timeout") or self.default_timeout) + else: + return asyncio.run( + self.execute(strategy_name, task_description, **kwargs) + ) + + +@dataclass +class OrchestrationContext: + """Passed to strategy functions. Provides fan_out, call_agent, synthesize.""" + + coordinator: Coordinator + budget_usd: float = 1.0 + timeout: float = 60.0 + session: AgentSession | None = field(default=None) + task_tracker: TaskTracker = field(default_factory=TaskTracker) + _spent_usd: float = field(default=0.0, init=False) + + def __post_init__(self): + if self.session is None: + self.session = AgentSession(conversation_id=generate_id()) + + @property + def budget_remaining(self) -> float: + return max(0.0, self.budget_usd - self._spent_usd) + + async def fan_out( + self, + assignments: list[tuple[str, str]], + timeout: float | None = None, + ) -> list[FanOutResult]: + """Dispatch multiple tasks in parallel and collect results.""" + results = await fan_out( + agent=self.coordinator.agent, + assignments=assignments, + session=self.session, + timeout=timeout or self.timeout, + max_budget_usd=self.budget_remaining, + max_concurrent=self.coordinator.max_concurrent, + task_tracker=self.task_tracker, + ) + + for r in results: + if r.usage: + self._spent_usd += r.usage.get("cost_usd", 0.0) + + return results + + async def call_agent( + self, + capability: str, + description: str, + timeout: float | None = None, + ) -> FanOutResult: + """Call a single agent. Shorthand for fan_out with one assignment.""" + results = await self.fan_out( + [(capability, description)], + timeout=timeout, + ) + return results[0] + + async def call_specific( + self, + webhook_url: str, + message: InvokeMessage, + timeout: float | None = None, + ) -> dict[str, Any]: + """Call a specific agent by webhook URL directly.""" + keypair = getattr(self.coordinator.agent, "keypair", None) + if keypair and not message.signature: + from zyndai_agent.signatures import sign_message as _sign + message.signature = _sign(message, keypair.private_key) + + x402_processor = getattr(self.coordinator.agent, "x402_processor", None) + if x402_processor is None: + raise RuntimeError("Agent has no x402_processor configured for outbound calls") + + try: + resp = await asyncio.to_thread( + x402_processor.session.post, + webhook_url, + json=message.model_dump(mode="json"), + timeout=timeout or self.timeout, + ) + except Exception as e: + raise RuntimeError(f"Failed to call agent at {webhook_url}: {e}") from e + + try: + return resp.json() + except ValueError as e: + raise RuntimeError( + f"Agent at {webhook_url} returned non-JSON response (HTTP {resp.status_code})" + ) from e + + def synthesize(self, results: list[FanOutResult]) -> dict[str, Any]: + """Combine multiple fan-out results into a single structured response. + + Returns a dict with both machine-readable fields (``results``, + ``failures``, ``agents_used``) and a human-readable ``briefing`` + string that downstream agents can consume directly instead of + needing to parse raw JSON. + """ + successful = [r for r in results if r.status == "success"] + failed = [r for r in results if r.status != "success"] + + # Build a human-readable briefing from successful results + briefing_parts: list[str] = [] + for r in successful: + section_header = f"[{r.capability}]" if r.capability else "[result]" + if r.agent_name: + section_header += f" (from {r.agent_name})" + + if r.result: + body = _format_result_for_briefing(r.result) + else: + body = "(no result data)" + + briefing_parts.append(f"{section_header}\n{body}") + + if failed: + fail_lines = [] + for r in failed: + fail_lines.append(f" - {r.capability}: {r.error or 'unknown error'}") + briefing_parts.append("[failures]\n" + "\n".join(fail_lines)) + + briefing = "\n\n".join(briefing_parts) + + return { + "status": "success" if successful else "error", + "briefing": briefing, + "results": [r.result for r in successful], + "failures": [ + {"agent": r.agent_name, "capability": r.capability, "error": r.error} + for r in failed + ], + "total_cost_usd": self._spent_usd, + "agents_used": [r.agent_name for r in successful], + } + + +def _format_result_for_briefing(result: dict[str, Any]) -> str: + """Convert a result dict into readable text for downstream agents. + + Handles common patterns: lists of findings/trends, key-value metrics, + and nested dicts. Falls back to a compact JSON representation only + when the structure is unrecognisable. + """ + lines: list[str] = [] + + for key, value in result.items(): + if isinstance(value, list): + lines.append(f" {key}:") + for item in value: + lines.append(f" - {item}") + elif isinstance(value, dict): + lines.append(f" {key}:") + for k, v in value.items(): + lines.append(f" {k}: {v}") + elif isinstance(value, float): + lines.append(f" {key}: {value:.2f}") + else: + lines.append(f" {key}: {value}") + + return "\n".join(lines) if lines else str(result) diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py new file mode 100644 index 0000000..3fb8ca9 --- /dev/null +++ b/zyndai_agent/orchestration/fan_out.py @@ -0,0 +1,295 @@ +""" +Parallel agent dispatch and result collection. + +fan_out() discovers agents by capability via the registry, sends typed +InvokeMessages in parallel, and collects results — all with automatic +x402 payment handling via the agent's existing requests.Session. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal, TYPE_CHECKING + +import requests as requests_lib + +from zyndai_agent.typed_messages import ( + InvokeMessage, + InvokeResponse, + generate_id, + parse_message, +) +from zyndai_agent.signatures import sign_message +from zyndai_agent.orchestration.task import Task, TaskTracker + +if TYPE_CHECKING: + from zyndai_agent.session import AgentSession + +logger = logging.getLogger(__name__) + + +@dataclass +class FanOutResult: + capability: str + agent_name: str = "" + agent_url: str = "" + status: Literal["success", "error", "timeout"] = "error" + result: dict[str, Any] | None = None + error: str | None = None + usage: dict[str, Any] | None = None + task: Task | None = None + + +async def fan_out( + agent: Any, + assignments: list[tuple[str, str]], + session: AgentSession | None = None, + timeout: float = 60.0, + max_budget_usd: float = 1.0, + max_concurrent: int = 10, + task_tracker: TaskTracker | None = None, +) -> list[FanOutResult]: + """ + Dispatch multiple tasks to agents in parallel and collect results. + + Args: + agent: ZyndAIAgent instance (used for search, signing, and x402 session). + assignments: List of (capability_keyword, task_description) tuples. + session: Optional AgentSession for conversation tracking. + timeout: Per-task timeout in seconds. + max_budget_usd: Total budget across all assignments. + max_concurrent: Max parallel HTTP calls. + task_tracker: Optional TaskTracker for lifecycle tracking. + + Returns: + List of FanOutResult in same order as input assignments. + """ + if timeout <= 0: + raise ValueError(f"timeout must be positive, got {timeout}") + + if task_tracker is None: + task_tracker = TaskTracker() + + semaphore = asyncio.Semaphore(max_concurrent) + per_task_budget = max_budget_usd / max(len(assignments), 1) + conversation_id = session.conversation_id if session else generate_id() + + async def _run_one(capability: str, description: str) -> FanOutResult: + async with semaphore: + task = task_tracker.create_task( + description=description, + timeout_seconds=timeout, + max_budget_usd=per_task_budget, + ) + + try: + agents_found = await asyncio.to_thread( + agent.search_entities, keyword=capability, limit=3 + ) + except Exception as e: + task.mark_failed(f"Registry search failed: {e}") + return FanOutResult( + capability=capability, + status="error", + error=f"Registry search failed: {e}", + task=task, + ) + + if not agents_found: + task.mark_failed(f"No agent or service found for '{capability}'") + return FanOutResult( + capability=capability, + status="error", + error=f"No agent or service found for '{capability}'", + task=task, + ) + + target = agents_found[0] + target_name = target.get("name", "unknown") + entity_type = target.get("entity_type") or target.get("type", "agent") + + # Services get a direct HTTP call, agents get InvokeMessage + if entity_type == "service": + endpoint = target.get("service_endpoint") or target.get("entity_url") or target.get("agent_url", "") + task.assigned_to = endpoint + task.mark_running() + + x402_proc = getattr(agent, "x402_processor", None) + http_session = x402_proc.session if x402_proc and hasattr(x402_proc, "session") else requests_lib + + try: + # Services have varied APIs — try multiple patterns: + # 1. GET /search?query=... (search-style services) + # 2. GET /?query=... (generic query) + # 3. POST / with JSON body (action-style services) + resp = None + for attempt_url, attempt_params, attempt_method in [ + (f"{endpoint}/search", {"query": description}, "GET"), + (endpoint, {"query": description}, "GET"), + (endpoint, None, "POST"), + ]: + try: + if attempt_method == "GET": + resp = await asyncio.to_thread( + http_session.get, attempt_url, + params=attempt_params, timeout=timeout, + ) + else: + resp = await asyncio.to_thread( + http_session.post, attempt_url, + json={"query": description, "task": description}, + timeout=timeout, + ) + if resp.status_code < 400: + break + except Exception as e: + logger.debug(f"Service probe failed for {attempt_url}: {e}") + continue + + if resp is None: + raise RuntimeError("All request patterns failed") + if resp.status_code < 400: + try: + result_dict = resp.json() + except Exception: + result_dict = {"raw": resp.text} + task.mark_completed(result_dict) + return FanOutResult( + capability=capability, agent_name=target_name, + agent_url=endpoint, status="success", + result=result_dict, task=task, + ) + else: + error = f"Service returned HTTP {resp.status_code}" + task.mark_failed(error) + return FanOutResult( + capability=capability, agent_name=target_name, + agent_url=endpoint, status="error", error=error, task=task, + ) + except Exception as e: + task.mark_failed(str(e)) + return FanOutResult( + capability=capability, agent_name=target_name, + agent_url=endpoint, status="error", error=str(e), task=task, + ) + + # Agent path: InvokeMessage to webhook + agent_url = target.get("entity_url") or target.get("agent_url", "") + invoke_url = f"{agent_url.rstrip('/')}/webhook/sync" + + task.assigned_to = invoke_url + task.mark_running() + + msg = InvokeMessage( + conversation_id=conversation_id, + sender_id=getattr(agent, "entity_id", "unknown"), + sender_public_key=getattr(agent.keypair, "public_key_string", None) if getattr(agent, "keypair", None) else None, + capability=capability, + payload={"task": description}, + max_budget_usd=per_task_budget, + timeout_seconds=int(timeout), + ) + + keypair = getattr(agent, "keypair", None) + if keypair: + msg.signature = sign_message(msg, keypair.private_key) + + x402_processor = getattr(agent, "x402_processor", None) + if x402_processor is None: + task.mark_failed("Agent has no x402_processor for outbound calls") + return FanOutResult( + capability=capability, + agent_name=target_name, + agent_url=agent_url, + status="error", + error="Agent has no x402_processor for outbound calls", + task=task, + ) + + try: + resp = await asyncio.to_thread( + x402_processor.session.post, + invoke_url, + json=msg.model_dump(mode="json"), + timeout=timeout, + ) + + resp_data = resp.json() + + if resp.status_code == 200 and resp_data.get("status") == "success": + response_content = resp_data.get("response", resp_data) + if isinstance(response_content, dict): + result_dict = dict(response_content) + usage = result_dict.pop("usage", None) + else: + result_dict = {"content": response_content} + usage = None + task.mark_completed(result_dict, usage) + + if session: + try: + typed_resp = parse_message(resp_data) if "type" in resp_data else None + if typed_resp: + session.add_message(typed_resp) + except Exception as e: + logger.warning(f"Failed to record response in session for capability={capability}: {e}") + + return FanOutResult( + capability=capability, + agent_name=target_name, + agent_url=agent_url, + status="success", + result=result_dict, + usage=usage, + task=task, + ) + else: + error = resp_data.get("error", f"HTTP {resp.status_code}") + task.mark_failed(error) + return FanOutResult( + capability=capability, + agent_name=target_name, + agent_url=agent_url, + status="error", + error=error, + task=task, + ) + + except (requests_lib.exceptions.Timeout, requests_lib.exceptions.ConnectTimeout, requests_lib.exceptions.ReadTimeout): + task.mark_timed_out() + return FanOutResult( + capability=capability, + agent_name=target_name, + agent_url=agent_url, + status="timeout", + error=f"Timed out after {timeout}s", + task=task, + ) + except Exception as e: + task.mark_failed(str(e)) + return FanOutResult( + capability=capability, + agent_name=target_name, + agent_url=agent_url, + status="error", + error=str(e), + task=task, + ) + + results = await asyncio.gather( + *[_run_one(cap, desc) for cap, desc in assignments], + return_exceptions=True, + ) + + final: list[FanOutResult] = [] + for i, r in enumerate(results): + if isinstance(r, FanOutResult): + final.append(r) + else: + cap = assignments[i][0] if i < len(assignments) else "unknown" + logger.error(f"Unexpected exception in fan_out for capability='{cap}': {r!r}") + final.append(FanOutResult(capability=cap, status="error", error=str(r))) + return final diff --git a/zyndai_agent/orchestration/task.py b/zyndai_agent/orchestration/task.py new file mode 100644 index 0000000..e363365 --- /dev/null +++ b/zyndai_agent/orchestration/task.py @@ -0,0 +1,136 @@ +""" +Task state machine for tracking orchestrated work units. + +Each Task represents a single unit of work dispatched to an agent. +TaskTracker provides CRUD and aggregate queries over a collection of tasks. +""" + +from __future__ import annotations + +import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any + + +class TaskStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + TIMED_OUT = "timed_out" + + +@dataclass +class Task: + task_id: str = field(default_factory=lambda: str(uuid.uuid4())) + description: str = "" + assigned_to: str | None = None + status: TaskStatus = TaskStatus.PENDING + result: dict[str, Any] | None = None + usage: dict[str, Any] | None = None + error: str | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + started_at: datetime | None = None + completed_at: datetime | None = None + timeout_seconds: float = 60.0 + max_budget_usd: float = 1.0 + + def mark_running(self) -> None: + self.status = TaskStatus.RUNNING + self.started_at = datetime.now(timezone.utc) + + def mark_completed(self, result: dict[str, Any], usage: dict[str, Any] | None = None) -> None: + self.status = TaskStatus.COMPLETED + self.result = result + self.usage = usage + self.completed_at = datetime.now(timezone.utc) + + def mark_failed(self, error: str) -> None: + self.status = TaskStatus.FAILED + self.error = error + self.completed_at = datetime.now(timezone.utc) + + def mark_cancelled(self) -> None: + self.status = TaskStatus.CANCELLED + self.completed_at = datetime.now(timezone.utc) + + def mark_timed_out(self) -> None: + self.status = TaskStatus.TIMED_OUT + self.error = f"Timed out after {self.timeout_seconds}s" + self.completed_at = datetime.now(timezone.utc) + + @property + def is_terminal(self) -> bool: + return self.status in ( + TaskStatus.COMPLETED, + TaskStatus.FAILED, + TaskStatus.CANCELLED, + TaskStatus.TIMED_OUT, + ) + + @property + def duration_ms(self) -> float | None: + if self.started_at is None: + return None + end = self.completed_at or datetime.now(timezone.utc) + return (end - self.started_at).total_seconds() * 1000 + + +class TaskTracker: + """Thread-safe tracker for a collection of tasks.""" + + def __init__(self) -> None: + self._tasks: dict[str, Task] = {} + self._lock = threading.RLock() + + def create_task( + self, + description: str, + assigned_to: str | None = None, + timeout_seconds: float = 60.0, + max_budget_usd: float = 1.0, + ) -> Task: + task = Task( + description=description, + assigned_to=assigned_to, + timeout_seconds=timeout_seconds, + max_budget_usd=max_budget_usd, + ) + with self._lock: + self._tasks[task.task_id] = task + return task + + def get_task(self, task_id: str) -> Task | None: + with self._lock: + return self._tasks.get(task_id) + + def active_tasks(self) -> list[Task]: + with self._lock: + return [t for t in self._tasks.values() if not t.is_terminal] + + def completed_tasks(self) -> list[Task]: + with self._lock: + return [t for t in self._tasks.values() if t.status == TaskStatus.COMPLETED] + + def total_cost(self) -> float: + with self._lock: + total = 0.0 + for t in self._tasks.values(): + if t.usage: + total += t.usage.get("cost_usd", 0.0) + return total + + def summary(self) -> dict[str, Any]: + with self._lock: + by_status: dict[str, int] = {} + for t in self._tasks.values(): + by_status[t.status.value] = by_status.get(t.status.value, 0) + 1 + return { + "total": len(self._tasks), + "by_status": by_status, + "total_cost_usd": self.total_cost(), + } diff --git a/zyndai_agent/search.py b/zyndai_agent/search.py index 611d9a9..36e2384 100644 --- a/zyndai_agent/search.py +++ b/zyndai_agent/search.py @@ -99,7 +99,7 @@ def search_entities( def search_agents_by_capabilities( self, - capabilities: List[str] = [], + capabilities: Optional[List[str]] = None, top_k: Optional[int] = None ) -> List[AgentSearchResponse]: """ @@ -113,6 +113,8 @@ def search_agents_by_capabilities( Returns: List of matching agents """ + if capabilities is None: + capabilities = [] logger.info(f"Discovering agents by capabilities: {capabilities}") # Convert capabilities to both query and skills diff --git a/zyndai_agent/service_tools.py b/zyndai_agent/service_tools.py new file mode 100644 index 0000000..0d98c69 --- /dev/null +++ b/zyndai_agent/service_tools.py @@ -0,0 +1,350 @@ +""" +Dynamic service tool generation for AI agents. + +Converts registered services into callable tools that any LLM agent +framework can use. The pipeline: + +1. search_services() finds top matching services (pgvector semantic search) +2. Service specs are converted to tool schemas (function-calling format) +3. LLM picks the right tool and generates params +4. Tool executor calls the service endpoint + +Works with LangChain, CrewAI, PydanticAI, and raw function-calling APIs. +""" + +import json +import logging +import requests +from typing import Any, Dict, List, Optional, Callable + +logger = logging.getLogger(__name__) + +# Minimal OpenAPI-style specs for each gateway service +# These describe the actual API shape so the LLM can generate correct params +SERVICE_SPECS: Dict[str, Dict[str, Any]] = { + "weather": { + "name": "zynd_weather", + "description": "Get weather forecast for any location worldwide. Returns current temperature, wind speed, and forecast data.", + "parameters": { + "latitude": {"type": "number", "description": "Latitude of the location (e.g., 37.77 for San Francisco, 35.68 for Tokyo)"}, + "longitude": {"type": "number", "description": "Longitude of the location (e.g., -122.42 for San Francisco, 139.69 for Tokyo)"}, + }, + "required": ["latitude", "longitude"], + "method": "GET", + "path": "", + }, + "crypto": { + "name": "zynd_crypto_prices", + "description": "Get real-time cryptocurrency prices. Supports 10,000+ tokens including Bitcoin, Ethereum, Solana, etc.", + "parameters": { + "ids": {"type": "string", "description": "Comma-separated coin IDs (e.g., 'bitcoin,ethereum,solana')"}, + "vs_currencies": {"type": "string", "description": "Target currency (default: 'usd')"}, + }, + "required": ["ids"], + "method": "GET", + "path": "/price", + }, + "wikipedia": { + "name": "zynd_wikipedia", + "description": "Search Wikipedia or get article summaries. Use for general knowledge lookups about any topic.", + "parameters": { + "title": {"type": "string", "description": "Article title to get summary for (e.g., 'Ethereum', 'Tokyo', 'Machine learning')"}, + }, + "required": ["title"], + "method": "GET", + "path": "/summary", + }, + "translate": { + "name": "zynd_translate", + "description": "Translate text between 30+ languages.", + "parameters": { + "text": {"type": "string", "description": "Text to translate"}, + "source": {"type": "string", "description": "Source language code (e.g., 'en', 'es', 'fr', 'de', 'ja')"}, + "target": {"type": "string", "description": "Target language code"}, + }, + "required": ["text", "target"], + "method": "POST", + "path": "", + }, + "exchange": { + "name": "zynd_exchange_rates", + "description": "Get currency exchange rates for 170+ currencies.", + "parameters": { + "from": {"type": "string", "description": "Base currency code (e.g., 'USD', 'EUR', 'GBP')"}, + "to": {"type": "string", "description": "Target currency codes, comma-separated (e.g., 'EUR,JPY,GBP')"}, + }, + "required": ["from"], + "method": "GET", + "path": "/rate", + }, + "news": { + "name": "zynd_tech_news", + "description": "Get latest tech news from Hacker News. Returns top, new, or best stories.", + "parameters": { + "type": {"type": "string", "description": "Story type: 'top', 'new', 'best', 'ask', 'show', 'job'"}, + "limit": {"type": "integer", "description": "Number of stories to return (max 30)"}, + }, + "required": [], + "method": "GET", + "path": "/hn", + }, + "geocode": { + "name": "zynd_geocode", + "description": "Convert addresses to coordinates (geocoding) or coordinates to addresses (reverse geocoding).", + "parameters": { + "address": {"type": "string", "description": "Address or place name to geocode (e.g., 'Eiffel Tower Paris', '1600 Pennsylvania Ave')"}, + }, + "required": ["address"], + "method": "GET", + "path": "/geocode", + }, + "defi": { + "name": "zynd_defi_data", + "description": "Get DeFi protocol data: TVL (Total Value Locked), yield rates, and chain data from DeFi Llama.", + "parameters": { + "protocol": {"type": "string", "description": "Protocol name (e.g., 'aave', 'uniswap', 'lido'). Omit for top protocols."}, + }, + "required": [], + "method": "GET", + "path": "/tvl", + }, + "countries": { + "name": "zynd_countries", + "description": "Get country data: population, capital, languages, currencies, borders, flags.", + "parameters": { + "name": {"type": "string", "description": "Country name to search for (e.g., 'Japan', 'Brazil')"}, + }, + "required": ["name"], + "method": "GET", + "path": "/search", + }, + "books": { + "name": "zynd_books", + "description": "Search millions of books by title, author, or subject via Open Library.", + "parameters": { + "query": {"type": "string", "description": "Search query (title, author, or subject)"}, + }, + "required": ["query"], + "method": "GET", + "path": "/search", + }, + "wikidata": { + "name": "zynd_wikidata", + "description": "Search the Wikidata knowledge graph for structured entity data.", + "parameters": { + "query": {"type": "string", "description": "Entity to search for"}, + }, + "required": ["query"], + "method": "GET", + "path": "/search", + }, + "readability": { + "name": "zynd_extract_article", + "description": "Extract clean article content from any URL. Strips ads, navigation, and clutter.", + "parameters": { + "url": {"type": "string", "description": "URL of the article to extract"}, + }, + "required": ["url"], + "method": "POST", + "path": "/extract", + }, + "duckduckgo": { + "name": "zynd_instant_search", + "description": "Get instant answers and knowledge graph data from DuckDuckGo.", + "parameters": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + "method": "GET", + "path": "", + }, +} + + +def get_service_tools( + registry_url: str = "http://localhost:8080", + keyword: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, +) -> List[Dict[str, Any]]: + """ + Get tool definitions for services matching a query. + + Returns OpenAI function-calling format tools that can be passed to any LLM. + Uses semantic search to find the most relevant services (pgvector in AgentDNS). + + Args: + registry_url: AgentDNS URL + keyword: Search keyword to filter services + category: Category filter + limit: Max tools to return (keep at 3-7 for best LLM accuracy) + + Returns: + List of tool definitions in OpenAI function-calling format + """ + from zyndai_agent import dns_registry + + result = dns_registry.search_entities( + registry_url=registry_url, + query=keyword or "service", + entity_type="service", + max_results=limit, + ) + + tools = [] + for svc in result.get("results", []): + slug = _extract_slug(svc) + spec = SERVICE_SPECS.get(slug) + if not spec: + # Generate a generic tool for unknown services + spec = _generic_spec(svc) + + tools.append({ + "type": "function", + "function": { + "name": spec["name"], + "description": spec["description"], + "parameters": { + "type": "object", + "properties": spec["parameters"], + "required": spec.get("required", []), + }, + }, + "_zynd_meta": { + "service_id": svc.get("agent_id"), + "service_endpoint": svc.get("service_endpoint"), + "slug": slug, + "method": spec.get("method", "GET"), + "path": spec.get("path", ""), + }, + }) + + return tools + + +def execute_service_tool( + tool_name: str, + tool_args: Dict[str, Any], + tools: List[Dict[str, Any]], + x402_session: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Execute a tool call against the actual service. + + Args: + tool_name: The function name the LLM chose + tool_args: The arguments the LLM generated + tools: The tool list from get_service_tools() (contains _zynd_meta) + x402_session: Optional x402 requests session for paid services + + Returns: + The service's JSON response + """ + tool_def = next((t for t in tools if t["function"]["name"] == tool_name), None) + if not tool_def: + raise ValueError(f"Unknown tool: {tool_name}") + + meta = tool_def["_zynd_meta"] + endpoint = meta["service_endpoint"] + method = meta["method"] + path = meta.get("path", "") + + url = endpoint.rstrip("/") + path + http = x402_session.session if x402_session and hasattr(x402_session, "session") else requests + + if method.upper() == "POST": + resp = http.post(url, json=tool_args, timeout=30) + else: + resp = http.get(url, params=tool_args, timeout=30) + + if resp.status_code >= 400: + raise RuntimeError(f"Service returned {resp.status_code}: {resp.text[:200]}") + + try: + return resp.json() + except Exception: + return {"raw": resp.text} + + +def get_langchain_tools( + registry_url: str = "http://localhost:8080", + keyword: Optional[str] = None, + limit: int = 5, +) -> list: + """ + Get LangChain Tool objects for services matching a query. + + Returns ready-to-use LangChain tools that can be passed directly to + create_react_agent, AgentExecutor, or any LangChain agent. + + Requires: pip install langchain-core + """ + from langchain_core.tools import StructuredTool + from pydantic import create_model, Field + + tool_defs = get_service_tools(registry_url=registry_url, keyword=keyword, limit=limit) + lc_tools = [] + + for tool_def in tool_defs: + fn = tool_def["function"] + meta = tool_def["_zynd_meta"] + + # Build Pydantic model for args + fields = {} + for param_name, param_spec in fn["parameters"].get("properties", {}).items(): + ptype = str if param_spec.get("type") == "string" else ( + float if param_spec.get("type") == "number" else ( + int if param_spec.get("type") == "integer" else str + ) + ) + is_required = param_name in fn["parameters"].get("required", []) + if is_required: + fields[param_name] = (ptype, Field(description=param_spec.get("description", ""))) + else: + fields[param_name] = (Optional[ptype], Field(default=None, description=param_spec.get("description", ""))) + + ArgsModel = create_model(f"{fn['name']}_args", **fields) + + # Closure to capture meta + def make_fn(m, all_tools): + def service_fn(**kwargs) -> str: + result = execute_service_tool(m["name"], kwargs, all_tools) + return json.dumps(result, indent=2, default=str)[:10000] + return service_fn + + tool = StructuredTool( + name=fn["name"], + description=fn["description"], + func=make_fn({"name": fn["name"]}, tool_defs), + args_schema=ArgsModel, + ) + lc_tools.append(tool) + + return lc_tools + + +def _extract_slug(service: Dict[str, Any]) -> str: + """Extract the slug from a service's endpoint URL.""" + endpoint = service.get("service_endpoint", "") + if "/v1/" in endpoint: + return endpoint.split("/v1/")[-1].split("/")[0].split("?")[0] + name = service.get("name", "").lower() + for slug in SERVICE_SPECS: + if slug in name: + return slug + return "" + + +def _generic_spec(service: Dict[str, Any]) -> Dict[str, Any]: + """Generate a generic tool spec for a service without a known spec.""" + name = service.get("name", "unknown").lower().replace(" ", "_").replace("-", "_") + return { + "name": f"zynd_{name}", + "description": service.get("summary", service.get("name", "")), + "parameters": { + "query": {"type": "string", "description": "Query or input for the service"}, + }, + "required": ["query"], + "method": "GET", + "path": "", + } diff --git a/zyndai_agent/session.py b/zyndai_agent/session.py new file mode 100644 index 0000000..b03e827 --- /dev/null +++ b/zyndai_agent/session.py @@ -0,0 +1,123 @@ +""" +Stateful conversation sessions for agent-to-agent communication. + +AgentSession tracks messages, participants, shared context, and cumulative +cost for a single conversation_id. SessionManager provides lookup and +lifecycle management across all active sessions. +""" + +from __future__ import annotations + +import uuid +import threading +from collections import OrderedDict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +DEFAULT_MAX_SESSIONS = 1000 +DEFAULT_MESSAGE_LIMIT = 500 + + +@dataclass +class AgentSession: + session_id: str = field(default_factory=lambda: str(uuid.uuid4())) + conversation_id: str = "" + participants: list[str] = field(default_factory=list) + messages: list[Any] = field(default_factory=list) + context: dict[str, Any] = field(default_factory=dict) + total_cost_usd: float = 0.0 + message_limit: int = DEFAULT_MESSAGE_LIMIT + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + + def add_message(self, msg: Any) -> None: + with self._lock: + self.messages.append(msg) + if len(self.messages) > self.message_limit: + self.messages = self.messages[-self.message_limit:] + self.updated_at = datetime.now(timezone.utc) + + usage = getattr(msg, "usage", None) + if isinstance(usage, dict): + self.total_cost_usd += usage.get("cost_usd", 0.0) + + def get_history(self, limit: int = 50) -> list[Any]: + with self._lock: + return list(self.messages[-limit:]) + + def to_dict(self) -> dict[str, Any]: + def _serialize(msg: Any) -> dict: + if hasattr(msg, "model_dump"): + return msg.model_dump() + if hasattr(msg, "to_dict"): + return msg.to_dict() + return {"content": str(msg)} + + with self._lock: + return { + "session_id": self.session_id, + "conversation_id": self.conversation_id, + "participants": list(self.participants), + "messages": [_serialize(m) for m in self.messages], + "context": dict(self.context), + "total_cost_usd": self.total_cost_usd, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> AgentSession: + return cls( + session_id=data.get("session_id", str(uuid.uuid4())), + conversation_id=data.get("conversation_id", ""), + participants=data.get("participants", []), + messages=data.get("messages", []), + context=data.get("context", {}), + total_cost_usd=data.get("total_cost_usd", 0.0), + created_at=datetime.fromisoformat(data["created_at"]) if "created_at" in data else datetime.now(timezone.utc), + updated_at=datetime.fromisoformat(data["updated_at"]) if "updated_at" in data else datetime.now(timezone.utc), + ) + + +class SessionManager: + """Thread-safe manager for AgentSession instances with LRU eviction.""" + + def __init__(self, max_sessions: int = DEFAULT_MAX_SESSIONS) -> None: + self._sessions: OrderedDict[str, AgentSession] = OrderedDict() + self._max_sessions = max_sessions + self._lock = threading.Lock() + + def get_or_create(self, conversation_id: str, sender_id: str) -> AgentSession: + with self._lock: + if conversation_id in self._sessions: + self._sessions.move_to_end(conversation_id) + session = self._sessions[conversation_id] + if sender_id not in session.participants: + session.participants.append(sender_id) + return session + + session = AgentSession( + conversation_id=conversation_id, + participants=[sender_id], + ) + self._sessions[conversation_id] = session + + while len(self._sessions) > self._max_sessions: + self._sessions.popitem(last=False) + + return session + + def get_session(self, conversation_id: str) -> AgentSession | None: + with self._lock: + return self._sessions.get(conversation_id) + + @property + def active_sessions(self) -> list[AgentSession]: + with self._lock: + return list(self._sessions.values()) + + def close_session(self, conversation_id: str) -> None: + with self._lock: + self._sessions.pop(conversation_id, None) diff --git a/zyndai_agent/signatures.py b/zyndai_agent/signatures.py new file mode 100644 index 0000000..3fc4dc0 --- /dev/null +++ b/zyndai_agent/signatures.py @@ -0,0 +1,54 @@ +""" +Message signing and verification for the typed message protocol. + +Wraps the existing Ed25519 sign/verify from ed25519_identity.py to work +with Pydantic-based TypedMessage models. Uses canonical JSON serialization +(sorted keys, deterministic datetime encoding) for signature stability. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from zyndai_agent.ed25519_identity import sign as ed25519_sign, verify as ed25519_verify + +if TYPE_CHECKING: + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from zyndai_agent.typed_messages import MessageBase + + +def _canonical_bytes(message: MessageBase) -> bytes: + """ + Produce deterministic bytes from a typed message for signing/verification. + Excludes the 'signature' field so the signature doesn't cover itself. + Uses mode="json" so datetimes are ISO strings, not Python repr. + """ + data = message.model_dump(mode="json", exclude={"signature"}) + return json.dumps(data, sort_keys=True).encode("utf-8") + + +def sign_message(message: MessageBase, private_key: Ed25519PrivateKey) -> str: + """ + Sign a typed message with an Ed25519 private key. + + Returns signature in 'ed25519:' format matching the existing + ed25519_identity convention. + """ + payload = _canonical_bytes(message) + return ed25519_sign(private_key, payload) + + +def verify_message(message: MessageBase, public_key_b64: str) -> bool: + """ + Verify the Ed25519 signature on a typed message. + + Args: + message: The typed message with a populated 'signature' field. + public_key_b64: Base64-encoded 32-byte Ed25519 public key + (without the 'ed25519:' prefix). + + Returns True if valid, False otherwise. Never raises. + """ + payload = _canonical_bytes(message) + return ed25519_verify(public_key_b64, payload, message.signature) diff --git a/zyndai_agent/typed_messages.py b/zyndai_agent/typed_messages.py new file mode 100644 index 0000000..5e248cc --- /dev/null +++ b/zyndai_agent/typed_messages.py @@ -0,0 +1,177 @@ +""" +Typed message protocol for ZyndAI agent communication. + +Pydantic v2 discriminated union replacing the untyped AgentMessage format. +Legacy messages (plain 'content' string without 'type' field) are auto-wrapped +as InvokeMessage with capability="legacy" for backwards compatibility. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import Annotated, Literal, Union + +from pydantic import BaseModel, Field + +from zyndai_agent.message import AgentMessage + + +def generate_id() -> str: + return str(uuid.uuid4()) + + +class MessageBase(BaseModel): + """Shared fields for all typed messages.""" + + message_id: str = Field(default_factory=generate_id) + conversation_id: str = Field(default_factory=generate_id) + sender_id: str + sender_public_key: str | None = None + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + signature: str = "" + + +class InvokeMessage(MessageBase): + type: Literal["invoke"] = "invoke" + capability: str + payload: dict = Field(default_factory=dict) + max_budget_usd: float = 0.0 + timeout_seconds: int = 30 + in_reply_to: str | None = None + + +class InvokeResponse(MessageBase): + type: Literal["invoke_response"] = "invoke_response" + in_reply_to: str + status: Literal["success", "error", "partial"] + result: dict = Field(default_factory=dict) + usage: dict | None = None + + +class StreamChunk(MessageBase): + type: Literal["stream_chunk"] = "stream_chunk" + in_reply_to: str + chunk_index: int + content: str + is_final: bool = False + + +class TaskAssignment(MessageBase): + type: Literal["task_assignment"] = "task_assignment" + task_id: str + description: str + context: dict = Field(default_factory=dict) + constraints: dict = Field(default_factory=dict) + + +class TaskNotification(MessageBase): + type: Literal["task_notification"] = "task_notification" + task_id: str + in_reply_to: str + status: Literal["started", "progress", "completed", "failed"] + summary: str + result: dict | None = None + usage: dict | None = None + + +class ShutdownRequest(MessageBase): + type: Literal["shutdown_request"] = "shutdown_request" + reason: str | None = None + + +class ShutdownResponse(MessageBase): + type: Literal["shutdown_response"] = "shutdown_response" + in_reply_to: str + approved: bool + reason: str | None = None + + +TypedMessage = Annotated[ + Union[ + InvokeMessage, + InvokeResponse, + StreamChunk, + TaskAssignment, + TaskNotification, + ShutdownRequest, + ShutdownResponse, + ], + Field(discriminator="type"), +] + +_typed_message_adapter = None + + +def _get_adapter(): + global _typed_message_adapter + if _typed_message_adapter is None: + from pydantic import TypeAdapter + _typed_message_adapter = TypeAdapter(TypedMessage) + return _typed_message_adapter + + +def parse_message(raw: dict) -> TypedMessage: + """ + Parse raw dict into a typed message. + + If the dict has a 'content' field but no 'type' field, it's treated as a + legacy AgentMessage and wrapped as InvokeMessage with capability="legacy". + """ + if "type" not in raw and ("content" in raw or "prompt" in raw): + content = raw.get("content", raw.get("prompt", "")) + return InvokeMessage( + message_id=raw.get("message_id", generate_id()), + conversation_id=raw.get("conversation_id", generate_id()), + sender_id=raw.get("sender_id", "unknown"), + sender_public_key=raw.get("sender_public_key"), + timestamp=datetime.now(timezone.utc), + capability="legacy", + payload={ + "content": content, + "prompt": raw.get("prompt", content), + }, + max_budget_usd=0.0, + timeout_seconds=raw.get("timeout_seconds", 30), + in_reply_to=raw.get("in_reply_to"), + ) + + return _get_adapter().validate_python(raw) + + +def typed_to_legacy(msg: TypedMessage) -> AgentMessage: + """Convert a typed message back to the legacy AgentMessage format. + + Extracts human-readable content from typed message fields so that + handlers using ``message.content`` always see the actual task/text + rather than an empty string or a raw dict repr. + """ + if isinstance(msg, InvokeMessage): + # Priority: content > prompt > task > description > str(payload) + content = ( + msg.payload.get("content") + or msg.payload.get("prompt") + or msg.payload.get("task") + or msg.payload.get("description") + or str(msg.payload) + ) + elif isinstance(msg, InvokeResponse): + content = str(msg.result) + elif isinstance(msg, StreamChunk): + content = msg.content + elif isinstance(msg, TaskAssignment): + content = msg.description + elif isinstance(msg, TaskNotification): + content = msg.summary + else: + content = msg.model_dump_json() + + return AgentMessage( + content=content, + sender_id=msg.sender_id, + sender_public_key=msg.sender_public_key, + message_id=msg.message_id, + conversation_id=msg.conversation_id, + message_type=msg.type if hasattr(msg, "type") else "query", + in_reply_to=getattr(msg, "in_reply_to", None), + ) diff --git a/zyndai_agent/ui/__init__.py b/zyndai_agent/ui/__init__.py new file mode 100644 index 0000000..845b4d1 --- /dev/null +++ b/zyndai_agent/ui/__init__.py @@ -0,0 +1,19 @@ +"""AG-UI Protocol support for ZyndAI agents. + +Provides streaming UI events over SSE (Server-Sent Events). +Import UIEmitter for handler use: + + @agent.handler + async def invoke(msg, ui): + await ui.text("Working...") + await ui.tool_call("search", {"q": "python"}) +""" + +from zyndai_agent.ui.emitter import UIEmitter, NoOpUIEmitter +from zyndai_agent.ui.sse import SSEHandler + +__all__ = [ + "UIEmitter", + "NoOpUIEmitter", + "SSEHandler", +] diff --git a/zyndai_agent/ui/emitter.py b/zyndai_agent/ui/emitter.py new file mode 100644 index 0000000..3048c3b --- /dev/null +++ b/zyndai_agent/ui/emitter.py @@ -0,0 +1,232 @@ +"""UIEmitter: per-conversation AG-UI event streaming. + +Manages per-conversation asyncio.Queue for AG-UI events. +Backpressure: drops events if no subscriber (doesn't block invoke). +""" + +import asyncio +import time +import logging +from typing import Optional, Dict, Any, List +from zyndai_agent.ui.events import ( + AGUIEvent, + RunStartedEvent, + RunFinishedEvent, + RunErrorEvent, + TextMessageContentEvent, + ToolCallStartEvent, + ToolCallEndEvent, + StateDeltaEvent, + StateSnapshotEvent, + CustomEvent, +) + +logger = logging.getLogger(__name__) + + +class UIEmitter: + """ + Per-conversation AG-UI event emitter. + + Usage: + ui = UIEmitter(conversation_id="conv-123") + await ui.text("Working...") + await ui.tool_call("search", {"q": "python"}) + """ + + MAX_QUEUE_SIZE = 1000 + STATS_LOG_INTERVAL = 100 # Log stats every N events + + def __init__(self, conversation_id: str): + self.conversation_id = conversation_id + self.queue: asyncio.Queue = asyncio.Queue(maxsize=self.MAX_QUEUE_SIZE) + self.events_emitted = 0 + self.events_dropped = 0 + self.run_id = f"run-{int(time.time())}" + self._start_time = time.time() + + async def _emit(self, event: AGUIEvent) -> bool: + """ + Emit event to queue. Backpressure: drops if full (doesn't block). + + Returns True if emitted, False if dropped. + """ + try: + self.queue.put_nowait(event) + self.events_emitted += 1 + + if self.events_emitted % self.STATS_LOG_INTERVAL == 0: + logger.debug( + f"[{self.conversation_id}] emitted {self.events_emitted} events, " + f"dropped {self.events_dropped}" + ) + + return True + except asyncio.QueueFull: + self.events_dropped += 1 + logger.warning( + f"[{self.conversation_id}] event queue full, dropping (dropped={self.events_dropped})" + ) + return False + + async def run_started(self): + """Emit RUN_STARTED.""" + await self._emit( + RunStartedEvent(runId=self.run_id, timestamp=time.time()) + ) + + async def run_finished(self): + """Emit RUN_FINISHED.""" + elapsed_ms = int((time.time() - self._start_time) * 1000) + await self._emit( + RunFinishedEvent( + runId=self.run_id, + timestamp=time.time(), + elapsedMs=elapsed_ms, + ) + ) + + async def run_error(self, error: str): + """Emit RUN_ERROR.""" + await self._emit( + RunErrorEvent( + runId=self.run_id, + error=error, + timestamp=time.time(), + ) + ) + + async def text(self, content: str, index: int = 0): + """Emit TEXT_MESSAGE_CONTENT.""" + await self._emit( + TextMessageContentEvent( + contentBlockIndex=index, + text=content, + timestamp=time.time(), + ) + ) + + async def tool_call( + self, + tool_name: str, + tool_input: Dict[str, Any], + tool_use_id: Optional[str] = None, + ): + """Emit TOOL_CALL_START.""" + if tool_use_id is None: + tool_use_id = f"tool-{int(time.time() * 1000)}" + + await self._emit( + ToolCallStartEvent( + toolUseId=tool_use_id, + toolName=tool_name, + toolInput=tool_input, + timestamp=time.time(), + ) + ) + + async def tool_result( + self, + tool_use_id: str, + result: str, + ): + """Emit TOOL_CALL_END.""" + await self._emit( + ToolCallEndEvent( + toolUseId=tool_use_id, + toolResult=result, + timestamp=time.time(), + ) + ) + + async def state_delta(self, operations: List[Dict[str, Any]]): + """Emit STATE_DELTA (JSON-Patch operations).""" + await self._emit( + StateDeltaEvent( + operations=operations, + timestamp=time.time(), + ) + ) + + async def state_snapshot(self, state: Dict[str, Any]): + """Emit STATE_SNAPSHOT.""" + await self._emit( + StateSnapshotEvent( + state=state, + timestamp=time.time(), + ) + ) + + async def custom(self, widget_name: str, data: Dict[str, Any]): + """Emit CUSTOM (generative UI widget).""" + await self._emit( + CustomEvent( + name=widget_name, + data=data, + timestamp=time.time(), + ) + ) + + def get_queue(self) -> asyncio.Queue: + """Get the event queue (for SSE handler).""" + return self.queue + + def get_stats(self) -> Dict[str, Any]: + """Get emitter statistics.""" + return { + "conversation_id": self.conversation_id, + "run_id": self.run_id, + "events_emitted": self.events_emitted, + "events_dropped": self.events_dropped, + "queue_size": self.queue.qsize(), + "elapsed_seconds": time.time() - self._start_time, + } + + +class NoOpUIEmitter: + """ + No-op UIEmitter for agents without generative_ui=True. + + All methods are async no-ops; doesn't block invoke(). + """ + + def __init__(self, conversation_id: str): + self.conversation_id = conversation_id + + async def run_started(self): + pass + + async def run_finished(self): + pass + + async def run_error(self, error: str): + pass + + async def text(self, content: str, index: int = 0): + pass + + async def tool_call( + self, + tool_name: str, + tool_input: Dict[str, Any], + tool_use_id: Optional[str] = None, + ): + pass + + async def tool_result(self, tool_use_id: str, result: str): + pass + + async def state_delta(self, operations: List[Dict[str, Any]]): + pass + + async def state_snapshot(self, state: Dict[str, Any]): + pass + + async def custom(self, widget_name: str, data: Dict[str, Any]): + pass + + def get_queue(self): + return None + + def get_stats(self) -> Dict[str, Any]: + return {"type": "noop"} diff --git a/zyndai_agent/ui/events.py b/zyndai_agent/ui/events.py new file mode 100644 index 0000000..9ab9f11 --- /dev/null +++ b/zyndai_agent/ui/events.py @@ -0,0 +1,124 @@ +"""AG-UI protocol event types and serialization. + +Reference: https://github.com/ag-ui-protocol/ag-ui +""" + +import json +from typing import Any, Dict, Optional, List +from dataclasses import dataclass, asdict + + +@dataclass +class AGUIEvent: + """Base AG-UI event.""" + + type: str + + def to_json(self) -> str: + """Serialize to JSON line for SSE.""" + data = asdict(self) + return json.dumps(data) + + +@dataclass +class RunStartedEvent(AGUIEvent): + """RUN_STARTED: workflow begins.""" + + type: str = "RUN_STARTED" + runId: str = "" + timestamp: Optional[float] = None + + +@dataclass +class RunFinishedEvent(AGUIEvent): + """RUN_FINISHED: workflow ends.""" + + type: str = "RUN_FINISHED" + runId: str = "" + timestamp: Optional[float] = None + elapsedMs: Optional[int] = None + + +@dataclass +class RunErrorEvent(AGUIEvent): + """RUN_ERROR: workflow failed.""" + + type: str = "RUN_ERROR" + runId: str = "" + error: str = "" + timestamp: Optional[float] = None + + +@dataclass +class TextMessageContentEvent(AGUIEvent): + """TEXT_MESSAGE_CONTENT: streaming text.""" + + type: str = "TEXT_MESSAGE_CONTENT" + contentBlockIndex: int = 0 + text: str = "" + timestamp: Optional[float] = None + + +@dataclass +class ToolCallStartEvent(AGUIEvent): + """TOOL_CALL_START: tool invocation begins.""" + + type: str = "TOOL_CALL_START" + toolUseId: str = "" + toolName: str = "" + toolInput: Dict[str, Any] = None + timestamp: Optional[float] = None + + def __post_init__(self): + if self.toolInput is None: + self.toolInput = {} + + +@dataclass +class ToolCallEndEvent(AGUIEvent): + """TOOL_CALL_END: tool invocation completes.""" + + type: str = "TOOL_CALL_END" + toolUseId: str = "" + toolResult: Optional[str] = None + timestamp: Optional[float] = None + + +@dataclass +class StateDeltaEvent(AGUIEvent): + """STATE_DELTA: JSON-Patch state update.""" + + type: str = "STATE_DELTA" + operations: List[Dict[str, Any]] = None + timestamp: Optional[float] = None + + def __post_init__(self): + if self.operations is None: + self.operations = [] + + +@dataclass +class StateSnapshotEvent(AGUIEvent): + """STATE_SNAPSHOT: full state snapshot.""" + + type: str = "STATE_SNAPSHOT" + state: Dict[str, Any] = None + timestamp: Optional[float] = None + + def __post_init__(self): + if self.state is None: + self.state = {} + + +@dataclass +class CustomEvent(AGUIEvent): + """CUSTOM: generative UI widget.""" + + type: str = "CUSTOM" + name: str = "" + data: Dict[str, Any] = None + timestamp: Optional[float] = None + + def __post_init__(self): + if self.data is None: + self.data = {} diff --git a/zyndai_agent/ui/metrics.py b/zyndai_agent/ui/metrics.py new file mode 100644 index 0000000..6eee7ee --- /dev/null +++ b/zyndai_agent/ui/metrics.py @@ -0,0 +1,77 @@ +""" +Prometheus metrics for AG-UI streaming. + +Tracks: +- agui_events_emitted_total — Total events emitted (counter) +- agui_active_streams — Current number of open streams (gauge) +- agui_stream_duration_seconds — Time streams remain open (histogram) +""" + +import time +import logging +from typing import Optional, Dict +from dataclasses import dataclass, field + +logger = logging.getLogger("AGUIMetrics") + + +@dataclass +class StreamMetrics: + """In-memory metrics collection for AG-UI streams.""" + + events_emitted_total: int = 0 + active_streams_count: int = 0 + stream_durations: list = field(default_factory=list) + + def increment_events(self, count: int = 1): + """Increment total events emitted.""" + self.events_emitted_total += count + + def increment_active_stream(self): + """Increment active stream counter.""" + self.active_streams_count += 1 + + def decrement_active_stream(self): + """Decrement active stream counter.""" + self.active_streams_count = max(0, self.active_streams_count - 1) + + def record_stream_duration(self, duration_seconds: float): + """Record stream duration.""" + self.stream_durations.append(duration_seconds) + + def get_summary(self) -> Dict[str, any]: + """Get metrics summary.""" + avg_duration = ( + sum(self.stream_durations) / len(self.stream_durations) + if self.stream_durations + else 0 + ) + return { + "agui_events_emitted_total": self.events_emitted_total, + "agui_active_streams": self.active_streams_count, + "agui_stream_duration_avg_seconds": round(avg_duration, 2), + "agui_stream_count_total": len(self.stream_durations), + } + + +# Global metrics instance +_metrics: Optional[StreamMetrics] = None + + +def get_metrics() -> StreamMetrics: + """Get or create global metrics instance.""" + global _metrics + if _metrics is None: + _metrics = StreamMetrics() + return _metrics + + +def emit_metrics_log(): + """Log current metrics (for monitoring/debugging).""" + metrics = get_metrics() + summary = metrics.get_summary() + logger.info( + f"[AGUIMetrics] Events: {summary['agui_events_emitted_total']}, " + f"Active streams: {summary['agui_active_streams']}, " + f"Avg duration: {summary['agui_stream_duration_avg_seconds']}s" + ) diff --git a/zyndai_agent/ui/sse.py b/zyndai_agent/ui/sse.py new file mode 100644 index 0000000..580fa8e --- /dev/null +++ b/zyndai_agent/ui/sse.py @@ -0,0 +1,105 @@ +"""Flask SSE handler for AG-UI event streaming.""" + +import asyncio +import logging +from typing import Optional +from flask import Response + +logger = logging.getLogger(__name__) + + +class SSEHandler: + """ + Server-Sent Events handler for AG-UI events. + + Drains UIEmitter.queue and yields SSE-formatted events. + """ + + @staticmethod + async def stream_events( + queue: asyncio.Queue, + timeout_seconds: float = 300, + ): + """ + Async generator: drain queue and yield SSE events. + + Args: + queue: asyncio.Queue from UIEmitter + timeout_seconds: max time to wait for next event before closing + + Yields: + SSE-formatted event lines + """ + start_time = asyncio.get_event_loop().time() + + while True: + try: + # Wait for event with timeout + event = await asyncio.wait_for( + queue.get(), + timeout=timeout_seconds, + ) + + # Yield SSE-formatted event + json_line = event.to_json() + yield f"data: {json_line}\n\n" + + except asyncio.TimeoutError: + logger.debug("SSE stream timeout, closing") + break + except Exception as e: + logger.error(f"SSE stream error: {e}") + break + + @staticmethod + def create_response(queue: Optional[asyncio.Queue]) -> Response: + """ + Create Flask SSE Response from UIEmitter queue. + + Args: + queue: asyncio.Queue from UIEmitter (or None if no-op) + + Returns: + Flask Response with SSE headers + """ + if queue is None: + # No-op emitter + def noop_generator(): + yield "data: {}\n\n" + + return Response( + noop_generator(), + mimetype="text/event-stream", + ) + + async def event_generator(): + """Drains queue in async context.""" + async for event_line in SSEHandler.stream_events(queue): + yield event_line + + # Convert async generator to sync (Flask compatibility) + def sync_generator(): + """Wrapper to run async generator in sync context.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + async_gen = event_generator() + while True: + try: + yield loop.run_until_complete( + async_gen.__anext__() + ) + except StopAsyncIteration: + break + finally: + loop.close() + + return Response( + sync_generator(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + )