From 99248df2e6c182336aec5794167c4f603169ee61 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 3 Apr 2026 10:22:00 +0530 Subject: [PATCH 01/16] add typed message protocol with pydantic discriminated unions Introduces InvokeMessage, InvokeResponse, StreamChunk, TaskAssignment, TaskNotification, ShutdownRequest, ShutdownResponse as Pydantic v2 models. parse_message() auto-wraps legacy format for backwards compat. sign_message()/verify_message() wrap existing Ed25519 identity module. --- zyndai_agent/signatures.py | 54 ++++++++++ zyndai_agent/typed_messages.py | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 zyndai_agent/signatures.py create mode 100644 zyndai_agent/typed_messages.py 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), + ) From 1e5e50fdb1c408d8d354f0f0db6c246add1fb036 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 3 Apr 2026 16:45:00 +0530 Subject: [PATCH 02/16] add session tracking and task state machine AgentSession with thread-safe message tracking, LRU eviction, cost accumulation. SessionManager keyed by conversation_id. TaskStatus enum, Task dataclass with lifecycle transitions, thread-safe TaskTracker with aggregate queries. --- zyndai_agent/orchestration/__init__.py | 28 +++++ zyndai_agent/orchestration/task.py | 136 +++++++++++++++++++++++++ zyndai_agent/session.py | 123 ++++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 zyndai_agent/orchestration/__init__.py create mode 100644 zyndai_agent/orchestration/task.py create mode 100644 zyndai_agent/session.py 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/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/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) From 0501dedf4ce573deca44610ca818eafbdbb6ebc8 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Sat, 4 Apr 2026 11:30:00 +0530 Subject: [PATCH 03/16] implement parallel fan-out and coordinator orchestration fan_out() discovers agents via registry, sends signed InvokeMessages in parallel with asyncio.gather, handles x402 auto-payment, tracks tasks. Coordinator class with @strategy decorator and OrchestrationContext providing fan_out/call_agent/call_specific/synthesize API. synthesize() builds human-readable briefing for downstream agents. --- zyndai_agent/orchestration/coordinator.py | 269 ++++++++++++++++++++++ zyndai_agent/orchestration/fan_out.py | 227 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 zyndai_agent/orchestration/coordinator.py create mode 100644 zyndai_agent/orchestration/fan_out.py diff --git a/zyndai_agent/orchestration/coordinator.py b/zyndai_agent/orchestration/coordinator.py new file mode 100644 index 0000000..11695d9 --- /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=self.default_timeout * 2) + 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..929b0a8 --- /dev/null +++ b/zyndai_agent/orchestration/fan_out.py @@ -0,0 +1,227 @@ +""" +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_agents, 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 found for '{capability}'") + return FanOutResult( + capability=capability, + status="error", + error=f"No agent found for '{capability}'", + task=task, + ) + + target = agents_found[0] + target_name = target.get("name", "unknown") + agent_url = 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, "agent_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 From a23f425e35f996801328a9977146174be131cdae Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Sat, 4 Apr 2026 18:10:00 +0530 Subject: [PATCH 04/16] replace webhook polling with threading.Event, wire sessions and signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap time.sleep(0.1) loop with threading.Event for sync webhook responses. Route typed messages through parse_message → typed_to_legacy so payload content is extracted correctly. Add signature verification on incoming typed messages. Wire SessionManager into ZyndAIAgent with session-aware handler dispatch via inspect-based arity detection. --- zyndai_agent/agent.py | 72 +++++++++++++ zyndai_agent/webhook_communication.py | 139 ++++++++++++++++++-------- 2 files changed, 170 insertions(+), 41 deletions(-) diff --git a/zyndai_agent/agent.py b/zyndai_agent/agent.py index 90f3e3a..3c3184b 100644 --- a/zyndai_agent/agent.py +++ b/zyndai_agent/agent.py @@ -13,6 +13,7 @@ from zyndai_agent.webhook_communication import WebhookCommunicationManager from zyndai_agent.payment import X402PaymentProcessor from zyndai_agent.config_manager import ConfigManager +from zyndai_agent.session import SessionManager from zyndai_agent.ed25519_identity import ( Ed25519Keypair, keypair_from_private_bytes, @@ -96,6 +97,11 @@ class AgentConfig(BaseModel): price: Optional[str] = None + # ZyndPay integration (optional — pip install zyndai-agent[zyndpay]) + payment_policy: Optional[Any] = None # PaymentPolicy from zyndpay + verify_signatures: bool = False # Verify Ed25519 signatures on incoming webhooks + zyndpay_api_url: Optional[str] = None # ZyndPay API URL for smart routing + # Config directory for agent identity (allows multiple agents in same project) config_dir: Optional[str] = None @@ -168,6 +174,10 @@ def __init__(self, agent_config: AgentConfig): IdentityManager.__init__(self, agent_config.registry_url) SearchAndDiscoveryManager.__init__(self, registry_url=agent_config.registry_url) + # ZyndPay smart routing (optional — when zyndpay is installed + configured) + self.payment_router = None + self._init_zyndpay(agent_config) + # Build card dict from AgentConfig (used for serving and self-registration) self._static_card = resolve_card_from_config(agent_config) @@ -201,6 +211,9 @@ def _build_agent_card(): ngrok_auth_token=agent_config.ngrok_auth_token or os.environ.get("NGROK_AUTH_TOKEN"), ) + self._session_manager = SessionManager() + self._verify_signatures = agent_config.verify_signatures + elif agent_config.mqtt_broker_url is not None: self.communication_mode = "mqtt" legacy_config = config if not env_keypair else {} @@ -277,6 +290,21 @@ def set_custom_agent(self, invoke_fn: Callable[[str], str]): self.custom_invoke_fn = invoke_fn self.agent_framework = AgentFramework.CUSTOM + def get_session(self, conversation_id: str): + if hasattr(self, "_session_manager"): + return self._session_manager.get_session(conversation_id) + return None + + @property + def active_sessions(self) -> list: + if hasattr(self, "_session_manager"): + return self._session_manager.active_sessions + return [] + + @property + def sessions(self): + return getattr(self, "_session_manager", None) + def invoke(self, input_text: str, **kwargs) -> str: """ Invoke the agent with the given input, regardless of framework. @@ -326,6 +354,48 @@ def invoke(self, input_text: str, **kwargs) -> str: else: raise ValueError(f"Unknown agent framework: {self.agent_framework}") + def _init_zyndpay(self, agent_config: AgentConfig): + """Initialize ZyndPay PaymentRouter if installed and configured.""" + try: + from zyndpay import PaymentRouter, PaymentPolicy + from zyndpay.adapters.x402_adapter import X402Adapter + from zyndpay.adapters.escrow_adapter import EscrowAdapter + + policy = agent_config.payment_policy + if policy is None: + policy = PaymentPolicy() + + router = PaymentRouter(policy=policy) + + if self.keypair: + from zyndai_agent.utils import private_key_from_base64 + import base64 + seed_b64 = base64.b64encode(self.keypair.private_key_bytes).decode() + eth_private_key = private_key_from_base64(seed_b64) + + x402_adapter = X402Adapter( + private_key=eth_private_key, + ) + router.register_adapter(x402_adapter) + + try: + escrow_adapter = EscrowAdapter( + private_key=eth_private_key, + ) + router.register_adapter(escrow_adapter) + _log_ok("Escrow adapter registered (high-value/low-trust → on-chain escrow)") + except Exception as e: + _log_warn(f"Escrow adapter disabled: {e}") + + self.payment_router = router + _log_ok("ZyndPay PaymentRouter initialized (smart routing enabled)") + + except ImportError: + # zyndpay not installed — x402 direct mode only + pass + except Exception as e: + _log_warn(f"ZyndPay init failed: {e} — falling back to x402 direct") + def _start_heartbeat(self, registry_url: str): """Start a background thread that sends WebSocket heartbeats to the registry.""" @@ -471,6 +541,8 @@ def _display_agent_info(self): elif self.agent_config.use_ngrok: _console.print(f" [dim]Ngrok[/dim] [yellow]Configured (not connected)[/yellow]") _console.print(f" [dim]Price[/dim] {price}") + payment_mode = "ZyndPay (smart routing)" if self.payment_router else "x402 (direct)" + _console.print(f" [dim]Payment[/dim] {payment_mode}") _console.print() else: border = "=" * 60 diff --git a/zyndai_agent/webhook_communication.py b/zyndai_agent/webhook_communication.py index 63ccfa3..6dd0683 100644 --- a/zyndai_agent/webhook_communication.py +++ b/zyndai_agent/webhook_communication.py @@ -1,4 +1,5 @@ import time +import inspect import logging import threading import requests @@ -81,10 +82,15 @@ def __init__( self.message_handlers = [] self.target_webhook_url = None self.pending_responses = {} # Store responses by message_id + self._pending_events: Dict[str, threading.Event] = {} # Thread safety self._lock = threading.Lock() + # Optional features wired by ZyndAIAgent + self._verify_signatures: bool = False + self._session_manager = None + # Create Flask app self.flask_app = Flask(f"agent_{agent_id}") self.flask_app.logger.setLevel(logging.ERROR) # Suppress Flask logging @@ -165,23 +171,36 @@ def agent_card(): def _handle_webhook_request(self, sync=False): """Handle incoming webhook POST requests.""" try: - # Verify request is JSON if not request.is_json: logger.error("Received non-JSON request") return jsonify({"error": "Content-Type must be application/json"}), 400 payload = request.get_json() - # Parse message from dict (request.get_json() returns a dict, not a string) - message = AgentMessage.from_dict(payload) + if self._verify_signatures: + sig_error = self._verify_incoming_signature(payload) + if sig_error: + return sig_error + + # Typed messages (InvokeMessage etc.) store content inside payload, + # not at the top level — AgentMessage.from_dict would lose it. + if "type" in payload: + from zyndai_agent.typed_messages import parse_message, typed_to_legacy + try: + typed_msg = parse_message(payload) + message = typed_to_legacy(typed_msg) + message._typed = typed_msg + except Exception as e: + logger.warning(f"[{self.agent_id}] Typed message parse failed, falling back to legacy: {e}") + message = AgentMessage.from_dict(payload) + else: + message = AgentMessage.from_dict(payload) logger.info(f"[{self.agent_id}] Received message from {message.sender_id}") - # Auto-connect to sender if not connected if not self.is_agent_connected: self.is_agent_connected = True - # Store in history message_with_metadata = { "message": message, "received_at": time.time(), @@ -195,40 +214,40 @@ def _handle_webhook_request(self, sync=False): self.received_messages.append(message_with_metadata) self.message_history.append(message_with_metadata) - # Maintain history limit if len(self.message_history) > self.message_history_limit: self.message_history = self.message_history[ -self.message_history_limit : ] - # Check if synchronous response is requested + session = None + if self._session_manager is not None: + session = self._session_manager.get_or_create( + message.conversation_id, message.sender_id + ) + session.add_message(message) + if sync: - # Wait for handler to process and store response - # Invoke message handlers synchronously - for handler in self.message_handlers: - try: - handler(message, None) # No topic in webhook context - except Exception as e: - logger.error(f"Error in message handler: {e}") - - # Wait for response (with timeout) - timeout = 30 # 30 seconds - start_time = time.time() - while time.time() - start_time < timeout: - with self._lock: - if message.message_id in self.pending_responses: - response = self.pending_responses.pop(message.message_id) - return jsonify( - { - "status": "success", - "message_id": message.message_id, - "response": response, - "timestamp": time.time(), - } - ), 200 - time.sleep(0.1) # Small delay to avoid busy waiting - - # Timeout - no response received + event = threading.Event() + with self._lock: + self._pending_events[message.message_id] = event + + self._dispatch_to_handlers(message, session) + + event.wait(timeout=30) + + with self._lock: + self._pending_events.pop(message.message_id, None) + if message.message_id in self.pending_responses: + response = self.pending_responses.pop(message.message_id) + return jsonify( + { + "status": "success", + "message_id": message.message_id, + "response": response, + "timestamp": time.time(), + } + ), 200 + return jsonify( { "status": "timeout", @@ -238,14 +257,8 @@ def _handle_webhook_request(self, sync=False): } ), 408 else: - # Async mode - invoke handlers and return immediately - for handler in self.message_handlers: - try: - handler(message, None) # No topic in webhook context - except Exception as e: - logger.error(f"Error in message handler: {e}") - - # Return success + self._dispatch_to_handlers(message, session) + return jsonify( { "status": "received", @@ -258,6 +271,47 @@ def _handle_webhook_request(self, sync=False): logger.error(f"Error handling webhook request: {e}") return jsonify({"error": str(e)}), 500 + def _verify_incoming_signature(self, payload: dict): + """Verify Ed25519 signature on incoming typed message. Returns Flask error response or None.""" + if "type" not in payload: + return None + + from zyndai_agent.typed_messages import parse_message + from zyndai_agent.signatures import verify_message + + try: + typed_msg = parse_message(payload) + except Exception as e: + logger.warning(f"[{self.agent_id}] Failed to parse typed message for signature verification: {e}") + return jsonify({"error": "Malformed typed message"}), 400 + + if not typed_msg.signature or not typed_msg.sender_public_key: + return None + + pub_b64 = typed_msg.sender_public_key + if pub_b64.startswith("ed25519:"): + pub_b64 = pub_b64[len("ed25519:"):] + if not verify_message(typed_msg, pub_b64): + return jsonify({"error": "Invalid signature"}), 401 + + return None + + def _dispatch_to_handlers(self, message: AgentMessage, session=None): + """Invoke registered handlers, passing session to 3-arg handlers.""" + for handler in self.message_handlers: + try: + param_count = len(inspect.signature(handler).parameters) + except (ValueError, TypeError): + param_count = 2 + + try: + if param_count >= 3 and session is not None: + handler(message, None, session) + else: + handler(message, None) + except Exception as e: + logger.error(f"[{self.agent_id}] Handler {handler!r} raised: {e}") + def start_webhook_server(self): """Start Flask webhook server in background thread.""" if self.is_running: @@ -519,6 +573,9 @@ def set_response(self, message_id: str, response: str): """ with self._lock: self.pending_responses[message_id] = response + event = self._pending_events.get(message_id) + if event: + event.set() logger.info(f"[{self.agent_id}] Set response for message {message_id}") def get_connection_status(self) -> Dict[str, Any]: From bdaa95a52ce63d04b7eb86870110194d440cf6f8 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Sun, 5 Apr 2026 10:00:00 +0530 Subject: [PATCH 05/16] export orchestration API, add openai and pytest-asyncio deps --- pyproject.toml | 6 ++ uv.lock | 206 +++++++++++++++++++++++++++++++++++++-- zyndai_agent/__init__.py | 47 +++++++++ 3 files changed, 253 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 54ad4e7..b8b5565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ 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", @@ -30,8 +31,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/uv.lock b/uv.lock index 2427db7..76c5058 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -582,6 +582,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" @@ -891,6 +900,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 +1096,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 +1171,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" @@ -1190,6 +1288,25 @@ 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 = "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" @@ -1566,6 +1683,19 @@ 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-dotenv" version = "1.2.1" @@ -1751,6 +1881,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 +1906,15 @@ 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 = "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 = "tenacity" version = "9.1.2" @@ -1781,6 +1933,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" @@ -2178,28 +2342,40 @@ 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"] }, ] [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 +2384,30 @@ 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 = "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/zyndai_agent/__init__.py b/zyndai_agent/__init__.py index d2e3058..07f9351 100644 --- a/zyndai_agent/__init__.py +++ b/zyndai_agent/__init__.py @@ -18,6 +18,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__ = [ "ZyndAIAgent", "AgentConfig", @@ -43,4 +68,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", ] From 042eb193996cf042701673dc701135909f414b39 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Sun, 5 Apr 2026 15:30:00 +0530 Subject: [PATCH 06/16] add tests for typed messages, signatures, sessions, orchestration 60 tests: message parsing, legacy compat, validation errors, sign/verify round-trip, tamper detection, session lifecycle, task state machine, fan-out with mocked registry/HTTP, coordinator strategy execution, synthesize. --- tests/test_orchestration.py | 300 +++++++++++++++++++++++++++++++++++ tests/test_session.py | 107 +++++++++++++ tests/test_signatures.py | 49 ++++++ tests/test_typed_messages.py | 222 ++++++++++++++++++++++++++ 4 files changed, 678 insertions(+) create mode 100644 tests/test_orchestration.py create mode 100644 tests/test_session.py create mode 100644 tests/test_signatures.py create mode 100644 tests/test_typed_messages.py diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py new file mode 100644 index 0000000..be2ae34 --- /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.agent_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.agent_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 From 2d5c1a5d93ee9c0cd6811791fa60da2d3374256f Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Mon, 6 Apr 2026 12:00:00 +0530 Subject: [PATCH 07/16] add orchestration examples and AAPL stock research demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simple_typed_agent, worker_agent, coordinator_agent, multi_step_workflow for SDK usage patterns. e2e_local_test for automated verification. orchestration_demo and verbose variant with live logging for presentations. apple_stock_research: 4-agent pipeline (data + sentiment → strategy) with GPT-4o-mini and registry discovery. --- examples/apple_stock_research.py | 591 +++++++++++++++++++++++ examples/coordinator_agent.py | 80 +++ examples/e2e_local_test.py | 310 ++++++++++++ examples/multi_step_workflow.py | 93 ++++ examples/orchestration_demo.py | 642 +++++++++++++++++++++++++ examples/orchestration_demo_verbose.py | 515 ++++++++++++++++++++ examples/simple_typed_agent.py | 54 +++ examples/worker_agent.py | 48 ++ 8 files changed, 2333 insertions(+) create mode 100644 examples/apple_stock_research.py create mode 100644 examples/coordinator_agent.py create mode 100644 examples/e2e_local_test.py create mode 100644 examples/multi_step_workflow.py create mode 100644 examples/orchestration_demo.py create mode 100644 examples/orchestration_demo_verbose.py create mode 100644 examples/simple_typed_agent.py create mode 100644 examples/worker_agent.py diff --git a/examples/apple_stock_research.py b/examples/apple_stock_research.py new file mode 100644 index 0000000..4c3e6ac --- /dev/null +++ b/examples/apple_stock_research.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +""" +ZyndAI Market Research Demo — Apple Stock Analysis + +3 specialist agents + 1 coordinator analyze AAPL: + - Data Collector: fetches price history, financials, key metrics + - Sentiment Analyst: analyzes market sentiment, news, social signals + - Strategy Advisor: produces buy/hold/sell recommendation with rationale + +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 datetime import datetime +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) + +_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") + +_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() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# LLM +# ═══════════════════════════════════════════════════════════════════════════════ + +def ask_llm(agent_name, system, prompt, max_tokens=400): + if _llm: + log(agent_name, "Calling GPT-4o-mini...") + 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 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}") + 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} key={agent.keypair.public_key_b64[:16]}...") + 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 "" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Worker Handlers +# ═══════════════════════════════════════════════════════════════════════════════ + +def data_handler(agent): + def handler(msg, topic, session): + task = _extract(msg) + log("DATA", f"Received: \"{task[:70]}...\"") + + if _llm: + answer = ask_llm("DATA", + "You are a financial data analyst specializing in stock market data. " + "Return ONLY valid JSON (no markdown) with these keys: " + "ticker (str), current_price (float), price_52w_high (float), price_52w_low (float), " + "pe_ratio (float), market_cap_billions (float), revenue_growth_yoy (str), " + "eps (float), dividend_yield (str), key_metrics (list of 3 strings).", + f"Provide current financial data and key metrics for: {task}", + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = _data_fallback() + else: + log("DATA", "Fetching data with built-in logic...") + time.sleep(0.8) + result = _data_fallback() + + log("DATA", f"{GREEN}✓{RESET} Data ready — {list(result.keys())}") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +def _data_fallback(): + return { + "ticker": "AAPL", + "current_price": 228.50, + "price_52w_high": 260.10, + "price_52w_low": 164.08, + "pe_ratio": 37.8, + "market_cap_billions": 3480, + "revenue_growth_yoy": "+4.3%", + "eps": 6.04, + "dividend_yield": "0.44%", + "key_metrics": [ + "Services revenue hit $26.3B in Q1 2025, up 14% YoY", + "iPhone revenue declined 1% but ASP increased", + "Gross margin expanded to 46.9%, highest in a decade", + ], + } + + +def sentiment_handler(agent): + def handler(msg, topic, session): + task = _extract(msg) + log("SENTIMENT", f"Received: \"{task[:70]}...\"") + + if _llm: + answer = ask_llm("SENTIMENT", + "You are a market sentiment analyst. You analyze news, social media, analyst ratings, and institutional flow. " + "Return ONLY valid JSON (no markdown) with these keys: " + "overall_sentiment (str: bullish/neutral/bearish), confidence (float 0-1), " + "analyst_consensus (str), price_target_avg (float), " + "bull_signals (list of 3 strings), bear_signals (list of 2 strings), " + "recent_news (list of 2 strings).", + f"Analyze current market sentiment for: {task}", + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = _sentiment_fallback() + else: + log("SENTIMENT", "Analyzing sentiment with built-in logic...") + time.sleep(0.6) + result = _sentiment_fallback() + + log("SENTIMENT", f"{GREEN}✓{RESET} Sentiment ready — {result.get('overall_sentiment', 'unknown')}") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +def _sentiment_fallback(): + return { + "overall_sentiment": "bullish", + "confidence": 0.72, + "analyst_consensus": "Overweight", + "price_target_avg": 252.40, + "bull_signals": [ + "Services segment growing 14% YoY — becoming a recurring revenue machine", + "Apple Intelligence rollout driving upgrade cycle expectations", + "Share buyback program continues at $110B+ annually", + ], + "bear_signals": [ + "China revenue down 11% amid local competition from Huawei", + "Valuation premium at 37.8x PE — priced for perfection", + ], + "recent_news": [ + "Apple announces AI partnership with OpenAI for on-device models", + "EU Digital Markets Act forcing App Store fee restructuring", + ], + } + + +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. Given research data and sentiment analysis, " + "produce an actionable recommendation. " + "Return ONLY valid JSON (no markdown) with these keys: " + "recommendation (str: BUY/HOLD/SELL), confidence (float 0-1), " + "target_price (float), time_horizon (str), " + "rationale (str — 3-4 sentences), " + "risks (list of 2 strings), catalysts (list of 2 strings).", + f"Based on the following research and sentiment data, provide an investment recommendation:\n\n{task}", + max_tokens=500, + ) + answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + result = json.loads(answer) + except json.JSONDecodeError: + result = _strategy_fallback() + else: + log("STRATEGY", "Building recommendation with built-in logic...") + time.sleep(0.5) + result = _strategy_fallback() + + log("STRATEGY", f"{GREEN}✓{RESET} Recommendation: {BOLD}{result.get('recommendation', '?')}{RESET}") + agent.set_response(msg.message_id, json.dumps(result)) + return handler + + +def _strategy_fallback(): + return { + "recommendation": "HOLD", + "confidence": 0.68, + "target_price": 245.00, + "time_horizon": "6-12 months", + "rationale": ( + "Apple's fundamentals remain strong with Services growing at 14% and gross margins " + "at decade highs. However, the current PE of 37.8x leaves little room for error. " + "The Apple Intelligence rollout could drive an upgrade supercycle, but China headwinds " + "and regulatory pressure in the EU create near-term uncertainty. " + "Wait for a pullback to the $210-215 range for a better entry point." + ), + "risks": [ + "China revenue deterioration accelerates if Huawei gains more share", + "EU App Store regulation could reduce Services margin by 200-300bps", + ], + "catalysts": [ + "iPhone 17 with Apple Intelligence driving record upgrade cycle", + "Services revenue crossing $30B/quarter milestone", + ], + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Orchestration +# ═══════════════════════════════════════════════════════════════════════════════ + +async def call_worker(coordinator, session, tracker, name, label, url, task_desc, cost=0.001): + 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 with Ed25519...") + msg.signature = sign_message(msg, coordinator.keypair.private_key) + log("COORDINATOR", f"POST {CYAN}{url}/webhook/sync{RESET} → {label}") + + 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() + + # Suppress Flask noise + _orig = builtins.print + builtins.print = lambda *a, **k: (_orig(*a, **k) if "Incoming" not in " ".join(str(x) for x in a) else None) + + # ─── Phase 1: Data + Sentiment in parallel ─────────────────────────── + + divider("PHASE 1: DATA COLLECTION + SENTIMENT ANALYSIS (parallel)") + + log("COORDINATOR", "Analyzing AAPL — breaking into 2 parallel tasks...") + log("COORDINATOR", f" → DATA COLLECTOR: fetch financials, price, metrics") + log("COORDINATOR", f" → SENTIMENT ANALYST: news, analyst ratings, social signals") + log("COORDINATOR", "Dispatching both simultaneously...") + + p1_start = time.time() + data_result, sentiment_result = await asyncio.gather( + call_worker(coordinator, session, tracker, "data-collector", "DATA", + worker_urls["data-collector"], + "Fetch current financial data, price history, and key metrics for Apple Inc (AAPL)"), + call_worker(coordinator, session, tracker, "sentiment-analyst", "SENTIMENT", + worker_urls["sentiment-analyst"], + "Analyze current market sentiment, analyst ratings, news, and social signals for Apple Inc (AAPL)"), + ) + p1_ms = (time.time() - p1_start) * 1000 + log("COORDINATOR", f"Phase 1 complete in {p1_ms:.0f}ms (parallel)") + + # Show results + if data_result["status"] == "success": + r = data_result["result"] + log("COORDINATOR", "Reading DATA COLLECTOR results:") + log("COORDINATOR", f" Price: ${r.get('current_price', '?')} | PE: {r.get('pe_ratio', '?')} | MCap: ${r.get('market_cap_billions', '?')}B") + log("COORDINATOR", f" 52W Range: ${r.get('price_52w_low', '?')} - ${r.get('price_52w_high', '?')}") + for m in r.get("key_metrics", []): + log("COORDINATOR", f" • {m}") + + if sentiment_result["status"] == "success": + r = sentiment_result["result"] + log("COORDINATOR", "Reading SENTIMENT ANALYST results:") + log("COORDINATOR", f" Sentiment: {BOLD}{r.get('overall_sentiment', '?').upper()}{RESET} | Confidence: {r.get('confidence', '?')}") + log("COORDINATOR", f" Analyst consensus: {r.get('analyst_consensus', '?')} | Target: ${r.get('price_target_avg', '?')}") + + # ─── Phase 2: Strategy recommendation ───────────────────────────────── + + divider("PHASE 2: INVESTMENT STRATEGY (sequential, uses Phase 1)") + + log("COORDINATOR", "Synthesizing data + sentiment 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]\n{_format_result_for_briefing(data_d)}") + if sent_d: + parts.append(f"[Sentiment Analysis]\n{_format_result_for_briefing(sent_d)}") + briefing = "\n\n".join(parts) or "No data." + + 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"Based on the following financial data and sentiment analysis for Apple Inc (AAPL), " + f"provide an investment recommendation:\n\n{briefing}", + cost=0.002, + ) + p2_ms = (time.time() - p2_start) * 1000 + + builtins.print = _orig + + total_ms = p1_ms + p2_ms + + return { + "data": data_result, + "sentiment": sentiment_result, + "strategy": strategy_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} AAPL Market Research — ZyndAI Multi-Agent Pipeline{RESET}") + _real_print(f" {BOLD}{'═' * 64}{RESET}") + _real_print() + + log("SYSTEM", f"Target: {BOLD}Apple Inc (AAPL){RESET}") + log("SYSTEM", f"AI Backend: {AI_MODE}") + log("SYSTEM", f"Registry: {REGISTRY_URL}") + log("SYSTEM", f"Agents: 4 (coordinator + data collector + sentiment analyst + strategy advisor)") + + tmpdir = tempfile.mkdtemp(prefix="zyndai_aapl_") + + divider("BOOTING AGENTS") + + agents = {} + + agents["aapl-coordinator"] = boot_agent(tmpdir, "aapl-coordinator", + "Orchestrates market research pipeline for stock analysis", + ["orchestration", "market-research"], "orchestration", 7300) + + agents["data-collector"] = boot_agent(tmpdir, "data-collector", + "Collects financial data, price history, and key metrics for stocks", + ["financial-data", "stock-metrics", "fundamentals"], "finance", 7301) + + agents["sentiment-analyst"] = boot_agent(tmpdir, "sentiment-analyst", + "Analyzes market sentiment from news, social media, and analyst ratings", + ["sentiment-analysis", "market-sentiment", "news"], "analysis", 7302) + + agents["strategy-advisor"] = boot_agent(tmpdir, "strategy-advisor", + "Produces investment recommendations based on data and sentiment", + ["investment-strategy", "buy-sell", "portfolio"], "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 != "aapl-coordinator"} + + # Health check + 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} for specialist agents...") + + coordinator = agents["aapl-coordinator"] + for wname, keyword in [("data-collector", "financial-data"), ("sentiment-analyst", "sentiment"), ("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: + full = dns_registry.get_agent(REGISTRY_URL, match["agent_id"]) + url = (full or {}).get("agent_url", "") or base_url(agents[wname]) + worker_urls[wname] = url + score = match.get("score", 0) + log("COORDINATOR", f'{GREEN}✓{RESET} search("{keyword}") → {BOLD}{match["name"]}{RESET} score={score:.2f}') + else: + log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → resolving locally') + except Exception: + log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → fallback') + + # Run pipeline + 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} AAPL") + _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() + _real_print(f" {BOLD}Catalysts:{RESET}") + for c in s.get("catalysts", []): + _real_print(f" {GREEN}▲{RESET} {c}") + _real_print() + _real_print(f" {BOLD}Risks:{RESET}") + for r in s.get("risks", []): + _real_print(f" {RED}▼{RESET} {r}") + else: + _real_print(f" {RED}Strategy agent 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 (data + sentiment, parallel): {results['p1_ms']:.0f}ms") + log("SYSTEM", f"Phase 2 (strategy, sequential): {results['p2_ms']:.0f}ms") + log("SYSTEM", f"Total pipeline: {results['total_ms']:.0f}ms") + + d_ms = results["data"].get("duration_ms", 0) or 0 + s_ms = results["sentiment"].get("duration_ms", 0) or 0 + st_ms = results["strategy"].get("duration_ms", 0) or 0 + seq = d_ms + s_ms + st_ms + if seq > 0 and results["total_ms"] > 0: + log("SYSTEM", f"Parallelism: {seq / results['total_ms']:.1f}x faster than sequential") + + _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() + + +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/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/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/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.") From 274ec5fa1ec2dfd4dce394b467e2f34ccb614140 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Mon, 6 Apr 2026 23:53:24 +0530 Subject: [PATCH 08/16] use real Yahoo Finance data in stock research demo Data collector fetches live price, PE, margins, FCF, 52W range. Sentiment analyst pulls real news headlines + analyst ratings from yfinance, scores sentiment via GPT-4o-mini. Strategy advisor reasons over actual numbers. Supports any ticker via TICKER env var. --- examples/apple_stock_research.py | 466 ++++++++++++++++--------------- pyproject.toml | 1 + uv.lock | 276 ++++++++++++++++++ 3 files changed, 513 insertions(+), 230 deletions(-) diff --git a/examples/apple_stock_research.py b/examples/apple_stock_research.py index 4c3e6ac..1f159d4 100644 --- a/examples/apple_stock_research.py +++ b/examples/apple_stock_research.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -ZyndAI Market Research Demo — Apple Stock Analysis +ZyndAI Market Research Demo — Apple Stock Analysis (Real Data) -3 specialist agents + 1 coordinator analyze AAPL: - - Data Collector: fetches price history, financials, key metrics - - Sentiment Analyst: analyzes market sentiment, news, social signals - - Strategy Advisor: produces buy/hold/sell recommendation with rationale +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 @@ -23,7 +23,6 @@ import io import builtins import requests -from datetime import datetime from dotenv import load_dotenv load_dotenv() @@ -31,6 +30,8 @@ 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 @@ -54,6 +55,7 @@ def __exit__(self, *a): 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: @@ -108,23 +110,19 @@ def divider(label=""): _real_print() -# ═══════════════════════════════════════════════════════════════════════════════ -# LLM -# ═══════════════════════════════════════════════════════════════════════════════ - def ask_llm(agent_name, system, prompt, max_tokens=400): - if _llm: - log(agent_name, "Calling GPT-4o-mini...") - 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 "" + 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 # ═══════════════════════════════════════════════════════════════════════════════ @@ -136,7 +134,7 @@ def base_url(agent): def boot_agent(tmpdir, name, desc, skills, category, port): - log("SYSTEM", f"Booting {name}...", f"port={port} skills={skills}") + log("SYSTEM", f"Booting {name}...", f"port={port}") d = os.path.join(tmpdir, name) os.makedirs(d, exist_ok=True) with _Quiet(): @@ -149,7 +147,7 @@ def boot_agent(tmpdir, name, desc, skills, category, port): )) 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]}...") + f"id={agent.agent_id[:24]}...") dns_registry.update_agent(REGISTRY_URL, agent.agent_id, agent.keypair, {"agent_url": url}) return agent @@ -165,108 +163,154 @@ def _extract(msg): # ═══════════════════════════════════════════════════════════════════════════════ -# Worker Handlers +# 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[:70]}...\"") - - if _llm: - answer = ask_llm("DATA", - "You are a financial data analyst specializing in stock market data. " - "Return ONLY valid JSON (no markdown) with these keys: " - "ticker (str), current_price (float), price_52w_high (float), price_52w_low (float), " - "pe_ratio (float), market_cap_billions (float), revenue_growth_yoy (str), " - "eps (float), dividend_yield (str), key_metrics (list of 3 strings).", - f"Provide current financial data and key metrics for: {task}", - ) - answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() - try: - result = json.loads(answer) - except json.JSONDecodeError: - result = _data_fallback() - else: - log("DATA", "Fetching data with built-in logic...") - time.sleep(0.8) - result = _data_fallback() - - log("DATA", f"{GREEN}✓{RESET} Data ready — {list(result.keys())}") + 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 -def _data_fallback(): - return { - "ticker": "AAPL", - "current_price": 228.50, - "price_52w_high": 260.10, - "price_52w_low": 164.08, - "pe_ratio": 37.8, - "market_cap_billions": 3480, - "revenue_growth_yoy": "+4.3%", - "eps": 6.04, - "dividend_yield": "0.44%", - "key_metrics": [ - "Services revenue hit $26.3B in Q1 2025, up 14% YoY", - "iPhone revenue declined 1% but ASP increased", - "Gross margin expanded to 46.9%, highest in a decade", - ], - } - +# ═══════════════════════════════════════════════════════════════════════════════ +# 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[:70]}...\"") - - if _llm: - answer = ask_llm("SENTIMENT", - "You are a market sentiment analyst. You analyze news, social media, analyst ratings, and institutional flow. " - "Return ONLY valid JSON (no markdown) with these keys: " - "overall_sentiment (str: bullish/neutral/bearish), confidence (float 0-1), " - "analyst_consensus (str), price_target_avg (float), " - "bull_signals (list of 3 strings), bear_signals (list of 2 strings), " - "recent_news (list of 2 strings).", - f"Analyze current market sentiment for: {task}", + 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), ) - answer = answer.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + analysis = analysis.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() try: - result = json.loads(answer) + 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: - result = _sentiment_fallback() - else: - log("SENTIMENT", "Analyzing sentiment with built-in logic...") - time.sleep(0.6) - result = _sentiment_fallback() - - log("SENTIMENT", f"{GREEN}✓{RESET} Sentiment ready — {result.get('overall_sentiment', 'unknown')}") + 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 -def _sentiment_fallback(): - return { - "overall_sentiment": "bullish", - "confidence": 0.72, - "analyst_consensus": "Overweight", - "price_target_avg": 252.40, - "bull_signals": [ - "Services segment growing 14% YoY — becoming a recurring revenue machine", - "Apple Intelligence rollout driving upgrade cycle expectations", - "Share buyback program continues at $110B+ annually", - ], - "bear_signals": [ - "China revenue down 11% amid local competition from Huawei", - "Valuation premium at 37.8x PE — priced for perfection", - ], - "recent_news": [ - "Apple announces AI partnership with OpenAI for on-device models", - "EU Digital Markets Act forcing App Store fee restructuring", - ], - } - +# ═══════════════════════════════════════════════════════════════════════════════ +# Strategy Advisor — GPT-4o-mini on Real Data +# ═══════════════════════════════════════════════════════════════════════════════ def strategy_handler(agent): def handler(msg, topic, session): @@ -275,61 +319,41 @@ def handler(msg, topic, session): if _llm: answer = ask_llm("STRATEGY", - "You are a senior investment strategist. Given research data and sentiment analysis, " - "produce an actionable recommendation. " - "Return ONLY valid JSON (no markdown) with these keys: " - "recommendation (str: BUY/HOLD/SELL), confidence (float 0-1), " + "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 — 3-4 sentences), " - "risks (list of 2 strings), catalysts (list of 2 strings).", - f"Based on the following research and sentiment data, provide an investment recommendation:\n\n{task}", - max_tokens=500, + "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 = _strategy_fallback() + result = {"recommendation": "HOLD", "confidence": 0.5, + "rationale": answer, "risks": [], "catalysts": []} else: - log("STRATEGY", "Building recommendation with built-in logic...") - time.sleep(0.5) - result = _strategy_fallback() - - log("STRATEGY", f"{GREEN}✓{RESET} Recommendation: {BOLD}{result.get('recommendation', '?')}{RESET}") + 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 -def _strategy_fallback(): - return { - "recommendation": "HOLD", - "confidence": 0.68, - "target_price": 245.00, - "time_horizon": "6-12 months", - "rationale": ( - "Apple's fundamentals remain strong with Services growing at 14% and gross margins " - "at decade highs. However, the current PE of 37.8x leaves little room for error. " - "The Apple Intelligence rollout could drive an upgrade supercycle, but China headwinds " - "and regulatory pressure in the EU create near-term uncertainty. " - "Wait for a pullback to the $210-215 range for a better entry point." - ), - "risks": [ - "China revenue deterioration accelerates if Huawei gains more share", - "EU App Store regulation could reduce Services margin by 200-300bps", - ], - "catalysts": [ - "iPhone 17 with Apple Intelligence driving record upgrade cycle", - "Services revenue crossing $30B/quarter milestone", - ], - } - - # ═══════════════════════════════════════════════════════════════════════════════ # Orchestration # ═══════════════════════════════════════════════════════════════════════════════ async def call_worker(coordinator, session, tracker, name, label, url, task_desc, cost=0.001): - log("COORDINATOR", f"Building InvokeMessage for {label}") + log("COORDINATOR", f"Signing + dispatching to {label}...") msg = InvokeMessage( conversation_id=session.conversation_id, sender_id=coordinator.agent_id, @@ -337,9 +361,7 @@ async def call_worker(coordinator, session, tracker, name, label, url, task_desc capability=name, payload={"task": task_desc, "content": task_desc}, timeout_seconds=30, ) - log("COORDINATOR", f"Signing with Ed25519...") msg.signature = sign_message(msg, coordinator.keypair.private_key) - log("COORDINATOR", f"POST {CYAN}{url}/webhook/sync{RESET} → {label}") task = tracker.create_task(description=f"[{name}]", assigned_to=url) task.mark_running() @@ -370,61 +392,61 @@ async def run_pipeline(coordinator, worker_urls): session = AgentSession(conversation_id=generate_id()) tracker = TaskTracker() - # Suppress Flask noise _orig = builtins.print builtins.print = lambda *a, **k: (_orig(*a, **k) if "Incoming" not in " ".join(str(x) for x in a) else None) - # ─── Phase 1: Data + Sentiment in parallel ─────────────────────────── - - divider("PHASE 1: DATA COLLECTION + SENTIMENT ANALYSIS (parallel)") + divider(f"PHASE 1: DATA + SENTIMENT (parallel)") - log("COORDINATOR", "Analyzing AAPL — breaking into 2 parallel tasks...") - log("COORDINATOR", f" → DATA COLLECTOR: fetch financials, price, metrics") - log("COORDINATOR", f" → SENTIMENT ANALYST: news, analyst ratings, social signals") - log("COORDINATOR", "Dispatching both simultaneously...") + 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"], - "Fetch current financial data, price history, and key metrics for Apple Inc (AAPL)"), + f"Fetch live financial data and key metrics for {TICKER}"), call_worker(coordinator, session, tracker, "sentiment-analyst", "SENTIMENT", worker_urls["sentiment-analyst"], - "Analyze current market sentiment, analyst ratings, news, and social signals for Apple Inc (AAPL)"), + 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)") - # Show results + # Display real data if data_result["status"] == "success": - r = data_result["result"] - log("COORDINATOR", "Reading DATA COLLECTOR results:") - log("COORDINATOR", f" Price: ${r.get('current_price', '?')} | PE: {r.get('pe_ratio', '?')} | MCap: ${r.get('market_cap_billions', '?')}B") - log("COORDINATOR", f" 52W Range: ${r.get('price_52w_low', '?')} - ${r.get('price_52w_high', '?')}") - for m in r.get("key_metrics", []): - log("COORDINATOR", f" • {m}") + 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": - r = sentiment_result["result"] - log("COORDINATOR", "Reading SENTIMENT ANALYST results:") - log("COORDINATOR", f" Sentiment: {BOLD}{r.get('overall_sentiment', '?').upper()}{RESET} | Confidence: {r.get('confidence', '?')}") - log("COORDINATOR", f" Analyst consensus: {r.get('analyst_consensus', '?')} | Target: ${r.get('price_target_avg', '?')}") - - # ─── Phase 2: Strategy recommendation ───────────────────────────────── - - divider("PHASE 2: INVESTMENT STRATEGY (sequential, uses Phase 1)") - - log("COORDINATOR", "Synthesizing data + sentiment into briefing for strategist...") + 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]\n{_format_result_for_briefing(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 Analysis]\n{_format_result_for_briefing(sent_d)}") - briefing = "\n\n".join(parts) or "No data." + 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") @@ -432,21 +454,18 @@ async def run_pipeline(coordinator, worker_urls): strategy_result = await call_worker( coordinator, session, tracker, "strategy-advisor", "STRATEGY", worker_urls["strategy-advisor"], - f"Based on the following financial data and sentiment analysis for Apple Inc (AAPL), " - f"provide an investment recommendation:\n\n{briefing}", + 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 - total_ms = p1_ms + p2_ms - return { "data": data_result, "sentiment": sentiment_result, "strategy": strategy_result, - "p1_ms": p1_ms, "p2_ms": p2_ms, "total_ms": total_ms, + "p1_ms": p1_ms, "p2_ms": p2_ms, "total_ms": p1_ms + p2_ms, "task_summary": tracker.summary(), } @@ -461,44 +480,39 @@ def main(): _real_print() _real_print(f" {BOLD}{'═' * 64}{RESET}") - _real_print(f" {BOLD} AAPL Market Research — ZyndAI Multi-Agent Pipeline{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}Apple Inc (AAPL){RESET}") + 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}") - log("SYSTEM", f"Agents: 4 (coordinator + data collector + sentiment analyst + strategy advisor)") - tmpdir = tempfile.mkdtemp(prefix="zyndai_aapl_") + tmpdir = tempfile.mkdtemp(prefix="zyndai_stock_") divider("BOOTING AGENTS") agents = {} - - agents["aapl-coordinator"] = boot_agent(tmpdir, "aapl-coordinator", - "Orchestrates market research pipeline for stock analysis", + 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", - "Collects financial data, price history, and key metrics for stocks", - ["financial-data", "stock-metrics", "fundamentals"], "finance", 7301) - + "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 news, social media, and analyst ratings", - ["sentiment-analysis", "market-sentiment", "news"], "analysis", 7302) - + "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 based on data and sentiment", - ["investment-strategy", "buy-sell", "portfolio"], "advisory", 7303) + "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 != "aapl-coordinator"} + worker_urls = {n: base_url(a) for n, a in agents.items() if n != "stock-coordinator"} - # Health check for name, agent in agents.items(): url = base_url(agent) for _ in range(30): @@ -511,25 +525,18 @@ def main(): divider("DISCOVERING AGENTS VIA REGISTRY") - log("COORDINATOR", f"Searching {CYAN}{REGISTRY_URL}{RESET} for specialist agents...") - - coordinator = agents["aapl-coordinator"] - for wname, keyword in [("data-collector", "financial-data"), ("sentiment-analyst", "sentiment"), ("strategy-advisor", "investment-strategy")]: + 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: - full = dns_registry.get_agent(REGISTRY_URL, match["agent_id"]) - url = (full or {}).get("agent_url", "") or base_url(agents[wname]) - worker_urls[wname] = url score = match.get("score", 0) log("COORDINATOR", f'{GREEN}✓{RESET} search("{keyword}") → {BOLD}{match["name"]}{RESET} score={score:.2f}') - else: - log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → resolving locally') except Exception: - log("COORDINATOR", f'{YELLOW}⏱{RESET} search("{keyword}") → fallback') + pass - # Run pipeline results = asyncio.run(run_pipeline(coordinator, worker_urls)) # ─── Final Output ───────────────────────────────────────────────────── @@ -541,7 +548,9 @@ def main(): rec = s.get("recommendation", "?") rec_color = GREEN if rec == "BUY" else (YELLOW if rec == "HOLD" else RED) - _real_print(f" {BOLD}Ticker:{RESET} AAPL") + _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', '?')}") @@ -551,31 +560,26 @@ def main(): for line in textwrap.wrap(s.get("rationale", ""), 60): _real_print(f" {line}") _real_print() - _real_print(f" {BOLD}Catalysts:{RESET}") - for c in s.get("catalysts", []): - _real_print(f" {GREEN}▲{RESET} {c}") - _real_print() - _real_print(f" {BOLD}Risks:{RESET}") - for r in s.get("risks", []): - _real_print(f" {RED}▼{RESET} {r}") + 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 agent failed: {results['strategy'].get('error')}{RESET}") + _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 (data + sentiment, parallel): {results['p1_ms']:.0f}ms") - log("SYSTEM", f"Phase 2 (strategy, sequential): {results['p2_ms']:.0f}ms") - log("SYSTEM", f"Total pipeline: {results['total_ms']:.0f}ms") - - d_ms = results["data"].get("duration_ms", 0) or 0 - s_ms = results["sentiment"].get("duration_ms", 0) or 0 - st_ms = results["strategy"].get("duration_ms", 0) or 0 - seq = d_ms + s_ms + st_ms - if seq > 0 and results["total_ms"] > 0: - log("SYSTEM", f"Parallelism: {seq / results['total_ms']:.1f}x faster than sequential") + 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(): @@ -585,6 +589,8 @@ def main(): _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__": diff --git a/pyproject.toml b/pyproject.toml index b8b5565..81ddc5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "requests>=2.31.0", "rich>=13.0.0", "x402[evm,flask,requests]>=2.1.0", + "yfinance>=1.2.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 76c5058..a4e90e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,14 @@ version = 1 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" @@ -726,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" @@ -1279,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" @@ -1288,6 +1345,67 @@ 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" @@ -1416,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" @@ -1428,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" @@ -1521,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" @@ -1696,6 +1899,18 @@ 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" @@ -1705,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" @@ -1906,6 +2130,15 @@ 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" @@ -1915,6 +2148,15 @@ 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" @@ -1978,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" @@ -2283,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" @@ -2356,6 +2630,7 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "x402", extra = ["evm", "flask", "requests"] }, + { name = "yfinance" }, ] [package.optional-dependencies] @@ -2395,6 +2670,7 @@ requires-dist = [ { 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" }, ] From d079df95e1f060aa0db0c0fd4acd9c99498d1537 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 10 Apr 2026 18:08:03 +0530 Subject: [PATCH 09/16] feat: add services directory support on top of ZyndService refactor Built on ZyndBase/ZyndService/entity rename work: - search_services(), call_service(), use_service() on SearchAndDiscoveryManager - fan_out() now routes services (direct HTTP) vs agents (InvokeMessage) - entity_type param on search_agents() - Fixed test for /v1/entities rename --- tests/test_search.py | 2 +- zyndai_agent/orchestration/fan_out.py | 49 ++++++++++++++++-- zyndai_agent/search.py | 73 ++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/tests/test_search.py b/tests/test_search.py index 878c04b..d76bc1f 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -176,7 +176,7 @@ def test_get_agent_success(self, mock_get): mgr = SearchAndDiscoveryManager() result = mgr.get_agent_by_id("agdns:abc") assert result["agent_id"] == "agdns:abc" - mock_get.assert_called_once_with("http://localhost:8080/v1/agents/agdns:abc") + mock_get.assert_called_once_with("http://localhost:8080/v1/entities/agdns:abc") @patch("zyndai_agent.dns_registry.requests.get") def test_get_agent_not_found(self, mock_get): diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py index 929b0a8..562c03c 100644 --- a/zyndai_agent/orchestration/fan_out.py +++ b/zyndai_agent/orchestration/fan_out.py @@ -99,17 +99,60 @@ async def _run_one(capability: str, description: str) -> FanOutResult: ) if not agents_found: - task.mark_failed(f"No agent found for '{capability}'") + task.mark_failed(f"No agent or service found for '{capability}'") return FanOutResult( capability=capability, status="error", - error=f"No agent found for '{capability}'", + error=f"No agent or service found for '{capability}'", task=task, ) target = agents_found[0] target_name = target.get("name", "unknown") - agent_url = target.get("agent_url", "") + 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: + resp = await asyncio.to_thread( + http_session.post, endpoint, + json={"query": description, "task": description}, + timeout=timeout, + ) + 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 diff --git a/zyndai_agent/search.py b/zyndai_agent/search.py index b1ec84b..b881daf 100644 --- a/zyndai_agent/search.py +++ b/zyndai_agent/search.py @@ -2,7 +2,7 @@ import logging import requests -from typing import List, Optional, TypedDict +from typing import Any, Dict, List, Optional, TypedDict from zyndai_agent import dns_registry @@ -55,6 +55,7 @@ def search_agents( limit: int = 10, federated: bool = False, enrich: bool = False, + entity_type: Optional[str] = None, ) -> List[AgentSearchResponse]: """ Search for agents using the agent-dns POST /v1/search endpoint. @@ -90,6 +91,7 @@ def search_agents( max_results=limit, federated=federated, enrich=enrich, + entity_type=entity_type, ) agents = result.get("results", []) @@ -154,6 +156,75 @@ def get_agent_by_id(self, agent_id: str) -> Optional[AgentSearchResponse]: """ return dns_registry.get_agent(self.registry_url, agent_id) + def search_services( + self, + keyword: Optional[str] = None, + category: Optional[str] = None, + tags: Optional[List[str]] = None, + skills: Optional[List[str]] = None, + limit: int = 10, + ) -> List[AgentSearchResponse]: + """Search for services (not agents). Sets entity_type='service' automatically.""" + return self.search_agents( + keyword=keyword, category=category, tags=tags, + skills=skills, limit=limit, entity_type="service", + ) + + def call_service( + self, + service_id: str, + method: str = "GET", + path: str = "", + params: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + timeout: int = 30, + ) -> Dict[str, Any]: + """Call a registered service by its ID. Auto-uses x402 for paid services.""" + service = dns_registry.get_agent(self.registry_url, service_id) + if not service: + raise ValueError(f"Service not found: {service_id}") + + endpoint = service.get("service_endpoint") or service.get("entity_url") or service.get("agent_url") + if not endpoint: + raise ValueError(f"Service '{service.get('name')}' has no endpoint URL") + + url = endpoint.rstrip("/") + path + session = getattr(self, "x402_processor", None) + http = session.session if session and hasattr(session, "session") else requests + + resp = http.request( + method=method.upper(), url=url, params=params, + json=body if method.upper() in ("POST", "PUT", "PATCH") else None, + timeout=timeout, + ) + if resp.status_code >= 400: + raise RuntimeError(f"Service '{service.get('name')}' returned {resp.status_code}: {resp.text[:200]}") + try: + return resp.json() + except Exception: + return {"raw": resp.text} + + def use_service( + self, + keyword: str, + method: str = "GET", + path: str = "", + params: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + category: Optional[str] = None, + timeout: int = 30, + ) -> Dict[str, Any]: + """Discover a service by keyword and call it in one step.""" + services = self.search_services(keyword=keyword, category=category, limit=1) + if not services: + raise ValueError(f"No service found matching '{keyword}'") + service = services[0] + logger.info(f"Using service: {service.get('name')} ({service.get('agent_id')})") + return self.call_service( + service_id=service.get("agent_id"), + method=method, path=path, params=params, body=body, timeout=timeout, + ) + def get_agent_card(self, agent_id: str) -> Optional[dict]: """ Fetch an agent's Agent Card. From 500161b9bb2391b4ad30e23833f1ba1782f1f28a Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 10 Apr 2026 18:27:49 +0530 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20services=20directory=20=E2=80=94?= =?UTF-8?q?=20search,=20call,=20use=20services=20via=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search_services(), call_service(), use_service() on SearchAndDiscoveryManager - fan_out() routes services (direct HTTP) vs agents (InvokeMessage) - entity_type param on search_agents() - 12/12 live E2E tests passing against 21 registered services - Fixed test for /v1/entities rename --- zyndai_agent/search.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/zyndai_agent/search.py b/zyndai_agent/search.py index b881daf..1b50ff7 100644 --- a/zyndai_agent/search.py +++ b/zyndai_agent/search.py @@ -220,10 +220,27 @@ def use_service( raise ValueError(f"No service found matching '{keyword}'") service = services[0] logger.info(f"Using service: {service.get('name')} ({service.get('agent_id')})") - return self.call_service( - service_id=service.get("agent_id"), - method=method, path=path, params=params, body=body, timeout=timeout, + + # Use endpoint directly from search result (avoids extra lookup) + endpoint = service.get("service_endpoint") or service.get("entity_url") or service.get("agent_url") + if not endpoint: + raise ValueError(f"Service '{service.get('name')}' has no endpoint URL") + + url = endpoint.rstrip("/") + path + session = getattr(self, "x402_processor", None) + http = session.session if session and hasattr(session, "session") else requests + + resp = http.request( + method=method.upper(), url=url, params=params, + json=body if method.upper() in ("POST", "PUT", "PATCH") else None, + timeout=timeout, ) + if resp.status_code >= 400: + raise RuntimeError(f"Service '{service.get('name')}' returned {resp.status_code}: {resp.text[:200]}") + try: + return resp.json() + except Exception: + return {"raw": resp.text} def get_agent_card(self, agent_id: str) -> Optional[dict]: """ From 2fed72a117a79f5c7fc8121e01b19875e600f82b Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 10 Apr 2026 19:02:50 +0530 Subject: [PATCH 11/16] feat: dynamic service tool generation for LLM agents Services auto-become LangChain/CrewAI/function-calling tools: tools = get_service_tools(keyword='weather crypto', limit=5) # Returns OpenAI function-calling format tools lc_tools = get_langchain_tools(keyword='weather translate') # Returns ready-to-use LangChain StructuredTool objects Pipeline: 1. search_services() finds top 3-5 services (pgvector semantic) 2. Service specs convert to tool schemas (param types, descriptions) 3. LLM picks tool + generates correct params 4. execute_service_tool() calls the real API Keeps tool count at 3-5 per query for 92%+ LLM accuracy. Works with LangChain, CrewAI, PydanticAI, raw function-calling. --- zyndai_agent/orchestration/fan_out.py | 34 ++- zyndai_agent/service_tools.py | 350 ++++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 zyndai_agent/service_tools.py diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py index 562c03c..3a04ab6 100644 --- a/zyndai_agent/orchestration/fan_out.py +++ b/zyndai_agent/orchestration/fan_out.py @@ -121,11 +121,35 @@ async def _run_one(capability: str, description: str) -> FanOutResult: http_session = x402_proc.session if x402_proc and hasattr(x402_proc, "session") else requests_lib try: - resp = await asyncio.to_thread( - http_session.post, endpoint, - json={"query": description, "task": description}, - timeout=timeout, - ) + # 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: + continue + + if resp is None: + raise RuntimeError("All request patterns failed") if resp.status_code < 400: try: result_dict = resp.json() diff --git a/zyndai_agent/service_tools.py b/zyndai_agent/service_tools.py new file mode 100644 index 0000000..df8e35b --- /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_agents( + 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": "", + } From fedc4605a40d22b86dbe41adcb6f157b5a83f3a1 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 17 Apr 2026 15:54:34 +0530 Subject: [PATCH 12/16] align field names with agentdns api renames agent_url -> entity_url, type -> entity_type, pricing_model -> entity_pricing in signable payload, request body, and all callers. fixes signature mismatch that would break registration against current agentdns --- tests/test_dns_registry.py | 13 ++++++------- zynd_cli/commands/agent_cmd.py | 2 +- zynd_cli/commands/register.py | 4 ++-- zynd_cli/commands/service_cmd.py | 4 ++-- zyndai_agent/config_manager.py | 4 ++-- zyndai_agent/dns_registry.py | 32 ++++++++++++++------------------ 6 files changed, 27 insertions(+), 32 deletions(-) diff --git a/tests/test_dns_registry.py b/tests/test_dns_registry.py index ed29734..9f3500f 100644 --- a/tests/test_dns_registry.py +++ b/tests/test_dns_registry.py @@ -15,14 +15,14 @@ def test_register_success(self, mock_post): kp = generate_keypair() mock_response = MagicMock() mock_response.status_code = 201 - mock_response.json.return_value = {"agent_id": kp.agent_id} + mock_response.json.return_value = {"entity_id": kp.agent_id} mock_post.return_value = mock_response agent_id = dns_registry.register_agent( registry_url="http://localhost:8080", keypair=kp, name="Test Agent", - agent_url="http://localhost:5000", + entity_url="http://localhost:5000", category="test", tags=["test"], summary="A test agent", @@ -31,11 +31,10 @@ def test_register_success(self, mock_post): assert agent_id == kp.agent_id mock_post.assert_called_once() - # Verify payload structure call_kwargs = mock_post.call_args body = call_kwargs[1]["json"] assert body["name"] == "Test Agent" - assert body["agent_url"] == "http://localhost:5000" + assert body["entity_url"] == "http://localhost:5000" assert body["public_key"] == kp.public_key_string assert body["signature"].startswith("ed25519:") @@ -52,7 +51,7 @@ def test_register_failure(self, mock_post): registry_url="http://localhost:8080", keypair=kp, name="Test", - agent_url="http://localhost:5000", + entity_url="http://localhost:5000", ) @@ -66,7 +65,7 @@ def test_get_success(self, mock_get): result = dns_registry.get_agent("http://localhost:8080", "agdns:abc123") assert result["agent_id"] == "agdns:abc123" - mock_get.assert_called_once_with("http://localhost:8080/v1/agents/agdns:abc123") + mock_get.assert_called_once_with("http://localhost:8080/v1/entities/agdns:abc123") @patch("zyndai_agent.dns_registry.requests.get") def test_get_not_found(self, mock_get): @@ -198,7 +197,7 @@ def test_get_card_success(self, mock_get): result = dns_registry.get_agent_card("http://localhost:8080", "agdns:abc") assert result is not None - mock_get.assert_called_once_with("http://localhost:8080/v1/agents/agdns:abc/card") + mock_get.assert_called_once_with("http://localhost:8080/v1/entities/agdns:abc/card") @patch("zyndai_agent.dns_registry.requests.get") def test_get_card_not_found(self, mock_get): diff --git a/zynd_cli/commands/agent_cmd.py b/zynd_cli/commands/agent_cmd.py index c474f1a..6273aba 100644 --- a/zynd_cli/commands/agent_cmd.py +++ b/zynd_cli/commands/agent_cmd.py @@ -379,7 +379,7 @@ def _agent_register(args: argparse.Namespace): registry_url=registry_url, keypair=kp, name=config["name"], - agent_url=agent_url, + entity_url=agent_url, category=config.get("category", "general"), tags=config.get("tags", []), summary=config.get("summary", ""), diff --git a/zynd_cli/commands/register.py b/zynd_cli/commands/register.py index 0029962..2b32d3c 100644 --- a/zynd_cli/commands/register.py +++ b/zynd_cli/commands/register.py @@ -90,7 +90,7 @@ def run(args: argparse.Namespace): registry_url=registry_url, keypair=kp, name=args.name, - agent_url=args.agent_url, + entity_url=args.agent_url, category=args.category, tags=args.tags, summary=args.summary, @@ -165,7 +165,7 @@ def _register_from_card(args: argparse.Namespace): registry_url=registry_url, keypair=kp, name=card.get("name", ""), - agent_url=agent_url, + entity_url=agent_url, category=args.category or card.get("category", "general"), tags=args.tags or card.get("tags"), summary=args.summary or card.get("summary"), diff --git a/zynd_cli/commands/service_cmd.py b/zynd_cli/commands/service_cmd.py index 2cce9ca..969e702 100644 --- a/zynd_cli/commands/service_cmd.py +++ b/zynd_cli/commands/service_cmd.py @@ -265,7 +265,7 @@ def _service_register(args: argparse.Namespace): registry_url=registry_url, keypair=kp, name=config["name"], - agent_url="", + entity_url="", category=config.get("category", "general"), tags=config.get("tags", []), summary=config.get("summary", ""), @@ -275,7 +275,7 @@ def _service_register(args: argparse.Namespace): entity_type="service", service_endpoint=config.get("service_endpoint"), openapi_url=config.get("openapi_url"), - pricing_model=config.get("pricing_model"), + entity_pricing=config.get("entity_pricing"), ) fqan = get_agent_fqan(registry_url, service_id) print(f"\n Service registered!") diff --git a/zyndai_agent/config_manager.py b/zyndai_agent/config_manager.py index 822e94c..2ee9e60 100644 --- a/zyndai_agent/config_manager.py +++ b/zyndai_agent/config_manager.py @@ -94,7 +94,7 @@ def _migrate_legacy_config(config: dict, agent_config) -> dict: registry_url=agent_config.registry_url, keypair=kp, name=config.get("name", agent_config.name), - agent_url=agent_url, + entity_url=agent_url, category=getattr(agent_config, "category", "general"), tags=getattr(agent_config, "tags", None), summary=getattr(agent_config, "summary", None), @@ -153,7 +153,7 @@ def create_agent(agent_config, config_dir: str = None) -> dict: registry_url=agent_config.registry_url, keypair=kp, name=agent_config.name, - agent_url=agent_url, + entity_url=agent_url, category=getattr(agent_config, "category", "general"), tags=getattr(agent_config, "tags", None), summary=getattr(agent_config, "summary", None), diff --git a/zyndai_agent/dns_registry.py b/zyndai_agent/dns_registry.py index 1908ee4..8f1ced1 100644 --- a/zyndai_agent/dns_registry.py +++ b/zyndai_agent/dns_registry.py @@ -19,7 +19,7 @@ def register_agent( registry_url: str, keypair: Ed25519Keypair, name: str, - agent_url: str, + entity_url: str = "", category: str = "general", tags: Optional[List[str]] = None, summary: Optional[str] = None, @@ -31,35 +31,33 @@ def register_agent( entity_type: Optional[str] = None, service_endpoint: Optional[str] = None, openapi_url: Optional[str] = None, - pricing_model: Optional[dict] = None, + entity_pricing: Optional[dict] = None, ) -> str: """ Register an agent or service on the registry. - Builds signable payload, signs with Ed25519 key, and POSTs to /v1/agents - (or /v1/services for type=service). + Builds signable payload, signs with Ed25519 key, and POSTs to /v1/entities. Returns: entity_id: The registered agent/service ID """ # Build signable payload (sorted keys to match Go's json.Marshal) - signable = { - "agent_url": agent_url or "", + signable: dict = { "category": category, + "entity_url": entity_url or "", "name": name, "public_key": keypair.public_key_string, "summary": summary or "", "tags": tags or [], } if entity_type: - signable["type"] = entity_type + signable["entity_type"] = entity_type signable_bytes = json.dumps(signable, sort_keys=True, separators=(",", ":")).encode() signature = sign(keypair.private_key, signable_bytes) - # Build full registration request - body = { + body: dict = { "name": name, - "agent_url": agent_url or "", + "entity_url": entity_url or "", "category": category, "tags": tags or [], "summary": summary or "", @@ -68,13 +66,13 @@ def register_agent( } if entity_type: - body["type"] = entity_type + body["entity_type"] = entity_type if service_endpoint: body["service_endpoint"] = service_endpoint if openapi_url: body["openapi_url"] = openapi_url - if pricing_model: - body["pricing_model"] = pricing_model + if entity_pricing: + body["entity_pricing"] = entity_pricing if capability_summary: body["capability_summary"] = capability_summary if developer_id: @@ -86,10 +84,8 @@ def register_agent( if version: body["version"] = version - # Use unified /v1/entities endpoint - endpoint = "/v1/entities" resp = requests.post( - f"{registry_url}{endpoint}", + f"{registry_url}/v1/entities", json=body, headers={"Content-Type": "application/json"}, ) @@ -101,7 +97,7 @@ def register_agent( ) data = resp.json() - return data.get("agent_id", keypair.agent_id) + return data.get("entity_id", data.get("agent_id", keypair.agent_id)) def check_handle_available(registry_url: str, handle: str) -> dict: @@ -310,7 +306,7 @@ def search_agents( if fqan: body["fqan"] = fqan if entity_type: - body["type"] = entity_type + body["entity_type"] = entity_type if offset: body["offset"] = offset if timeout_ms is not None: From 85cfc90bfa7e19856bb421413b2defcb52cb4e01 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 17 Apr 2026 16:07:16 +0530 Subject: [PATCH 13/16] fix get_entity_fqan - search results use agent_id not entity_id was always returning None because it looked for entity_id in search results which only have agent_id --- zyndai_agent/dns_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zyndai_agent/dns_registry.py b/zyndai_agent/dns_registry.py index 1aba0e6..0fadd3d 100644 --- a/zyndai_agent/dns_registry.py +++ b/zyndai_agent/dns_registry.py @@ -466,7 +466,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 From c113eb776aeab1d8d216765d305fdab14fd431d7 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 17 Apr 2026 16:23:52 +0530 Subject: [PATCH 14/16] fix: broken search_agents refs, register argparse, duplicate except Co-Authored-By: Claude Opus 4.6 (1M context) --- zynd_cli/commands/register.py | 2 +- zyndai_agent/dns_registry.py | 3 --- zyndai_agent/orchestration/fan_out.py | 2 +- zyndai_agent/service_tools.py | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) 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/dns_registry.py b/zyndai_agent/dns_registry.py index 0fadd3d..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( diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py index 3a04ab6..0614284 100644 --- a/zyndai_agent/orchestration/fan_out.py +++ b/zyndai_agent/orchestration/fan_out.py @@ -87,7 +87,7 @@ async def _run_one(capability: str, description: str) -> FanOutResult: try: agents_found = await asyncio.to_thread( - agent.search_agents, keyword=capability, limit=3 + agent.search_entities, keyword=capability, limit=3 ) except Exception as e: task.mark_failed(f"Registry search failed: {e}") diff --git a/zyndai_agent/service_tools.py b/zyndai_agent/service_tools.py index df8e35b..0d98c69 100644 --- a/zyndai_agent/service_tools.py +++ b/zyndai_agent/service_tools.py @@ -184,7 +184,7 @@ def get_service_tools( """ from zyndai_agent import dns_registry - result = dns_registry.search_agents( + result = dns_registry.search_entities( registry_url=registry_url, query=keyword or "service", entity_type="service", From f3f94320c6ca4f9fef517a61e9ad7e2417bf2f1d Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 17 Apr 2026 16:31:20 +0530 Subject: [PATCH 15/16] fix: keypair env var, dead imports, sender_id, mutable default Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_orchestration.py | 4 ++-- zyndai_agent/agent.py | 12 ------------ zyndai_agent/base.py | 8 ++++---- zyndai_agent/orchestration/fan_out.py | 2 +- zyndai_agent/search.py | 4 +++- 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py index be2ae34..937b9c4 100644 --- a/tests/test_orchestration.py +++ b/tests/test_orchestration.py @@ -119,7 +119,7 @@ class TestFanOut: @pytest.fixture def mock_agent(self): agent = MagicMock() - agent.agent_id = "coordinator-1" + agent.entity_id = "coordinator-1" agent.keypair = None agent.x402_processor.session = MagicMock() return agent @@ -217,7 +217,7 @@ class TestCoordinator: @pytest.fixture def mock_agent(self): agent = MagicMock() - agent.agent_id = "coord-1" + agent.entity_id = "coord-1" agent.keypair = None agent.x402_processor.session = MagicMock() agent.search_agents = MagicMock(return_value=[]) 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 9570ba5..00d272b 100644 --- a/zyndai_agent/base.py +++ b/zyndai_agent/base.py @@ -106,7 +106,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 " @@ -192,10 +192,10 @@ 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) if env_path: config.keypair_path = env_path try: diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py index 0614284..5548ec1 100644 --- a/zyndai_agent/orchestration/fan_out.py +++ b/zyndai_agent/orchestration/fan_out.py @@ -184,7 +184,7 @@ async def _run_one(capability: str, description: str) -> FanOutResult: msg = InvokeMessage( conversation_id=conversation_id, - sender_id=getattr(agent, "agent_id", "unknown"), + 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}, 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 From 12c1520edaed48c78bf03da95a82999e84086b47 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Fri, 17 Apr 2026 16:33:53 +0530 Subject: [PATCH 16/16] cleanup: logger over print, no config mutation, better error msgs, timeout fix Co-Authored-By: Claude Opus 4.6 (1M context) --- zynd_cli/commands/_entity_base.py | 3 +++ zyndai_agent/base.py | 5 +++-- zyndai_agent/config_manager.py | 18 +++++++++--------- zyndai_agent/orchestration/coordinator.py | 2 +- zyndai_agent/orchestration/fan_out.py | 3 ++- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/zynd_cli/commands/_entity_base.py b/zynd_cli/commands/_entity_base.py index 64dd0ca..d4525fe 100644 --- a/zynd_cli/commands/_entity_base.py +++ b/zynd_cli/commands/_entity_base.py @@ -279,6 +279,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/zyndai_agent/base.py b/zyndai_agent/base.py index 00d272b..472a95b 100644 --- a/zyndai_agent/base.py +++ b/zyndai_agent/base.py @@ -196,10 +196,11 @@ def _resolve_keypair(config, entity_type: str = "agent") -> Optional[Ed25519Keyp """Resolve keypair from env vars or config.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 7fbd8bb..af016f3 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/orchestration/coordinator.py b/zyndai_agent/orchestration/coordinator.py index 11695d9..1e98878 100644 --- a/zyndai_agent/orchestration/coordinator.py +++ b/zyndai_agent/orchestration/coordinator.py @@ -103,7 +103,7 @@ def execute_sync( asyncio.run, self.execute(strategy_name, task_description, **kwargs), ) - return future.result(timeout=self.default_timeout * 2) + return future.result(timeout=kwargs.get("timeout") or self.default_timeout) else: return asyncio.run( self.execute(strategy_name, task_description, **kwargs) diff --git a/zyndai_agent/orchestration/fan_out.py b/zyndai_agent/orchestration/fan_out.py index 5548ec1..3fb8ca9 100644 --- a/zyndai_agent/orchestration/fan_out.py +++ b/zyndai_agent/orchestration/fan_out.py @@ -145,7 +145,8 @@ async def _run_one(capability: str, description: str) -> FanOutResult: ) if resp.status_code < 400: break - except Exception: + except Exception as e: + logger.debug(f"Service probe failed for {attempt_url}: {e}") continue if resp is None: