diff --git a/services/domyn_agent_example/.env.example b/services/domyn_agent_example/.env.example new file mode 100644 index 0000000..d9e73d8 --- /dev/null +++ b/services/domyn_agent_example/.env.example @@ -0,0 +1,7 @@ +DOMYN_API_KEY=your_api_key_here +DOMYN_CHANNEL_ID=your_channel_id_here +DOMYN_SPACE_ID=your_space_id_here +DOMYN_BASE_URL=analytics-az.crystal.io +VLLM_API_KEY=your_vllm_api_key_here +VLLM_BASE_URL=https://gateway-dev.llm.crystal.ai/v1 +VLLM_MODEL=Qwen/Qwen3-32B diff --git a/services/domyn_agent_example/Dockerfile b/services/domyn_agent_example/Dockerfile new file mode 100644 index 0000000..35a74ed --- /dev/null +++ b/services/domyn_agent_example/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install domyn-agents from the bundled wheel (no private index required) +COPY wheels/ wheels/ +RUN pip install --no-cache-dir wheels/domyn_agents-*.whl + +# Copy the agent +COPY agent_expose.py . + +# Connect to the Domyn platform via WebSocket relay +# All required values come from environment variables (see .env.example) +CMD ["sh", "-c", \ + "domyn expose agent_expose:agent \ + --channel-id $DOMYN_CHANNEL_ID \ + --space-id $DOMYN_SPACE_ID \ + --base-url $DOMYN_BASE_URL"] diff --git a/services/domyn_agent_example/README.md b/services/domyn_agent_example/README.md new file mode 100644 index 0000000..26dba8b --- /dev/null +++ b/services/domyn_agent_example/README.md @@ -0,0 +1,218 @@ +# Domyn Platform — Native domyn-agents ReAct Agent Blueprint + +A ready-to-run example of a native domyn-agents ReAct agent connected to the Domyn platform as a subagent. The agent uses an LLM (via a vLLM-compatible endpoint) and a set of built-in tools to answer tasks sent by the platform orchestrator. + +--- + +## What's included + +``` +domyn_agent_example/ +├── agent_expose.py # The agent definition — modify tools and LLM config here +├── test_local.py # Run the agent locally without a WebSocket connection +├── Dockerfile # Container image definition +├── docker-compose.yml # One-command local Docker run +├── requirements.txt # Python dependencies (excl. domyn-agents wheel) +├── .env.example # Required environment variables +└── wheels/ # Place the domyn-agents .whl file here +``` + +--- + +## Prerequisites + +- Python 3.11+ (for local/laptop runs) +- Docker (for containerised runs and VM deployment) +- The `domyn-agents` wheel file — place it in `wheels/` + +--- + +## Step 0 — Prepare the wheel + +Obtain the `domyn_agents-*.whl` file and place it inside the `wheels/` directory before running anything: + +```bash +ls wheels/ +``` + +--- + +## Step 1 — Configure environment variables + +```bash +cp .env.example .env +# Edit .env and fill in your values +``` + +| Variable | Description | +|---|---| +| `DOMYN_API_KEY` | API key from the Domyn platform | +| `DOMYN_CHANNEL_ID` | WebSocket channel ID assigned to this subagent | +| `DOMYN_SPACE_ID` | Your space ID on the platform | +| `DOMYN_BASE_URL` | Platform base URL | +| `VLLM_API_KEY` | API key for the vLLM/LLM gateway | +| `VLLM_BASE_URL` | Base URL of the LLM gateway (OpenAI-compatible, full path to `/v1/chat/completions`) | +| `VLLM_MODEL` | Model name to use (e.g. `Qwen/Qwen3-32B`) | + +--- + +## Running locally (laptop) + +Install dependencies: + +```bash +pip install wheels/domyn_agents-*.whl +``` + +Test the agent without any platform connection: + +```bash +python test_local.py +# or with a custom task: +python test_local.py "Add 5 and 7" +``` + +Connect to the platform: + +```bash +source .env +domyn expose agent_expose:agent \ + --channel-id $DOMYN_CHANNEL_ID \ + --space-id $DOMYN_SPACE_ID \ + --base-url $DOMYN_BASE_URL +``` + +`domyn expose` accepts a `module:symbol` argument pointing to a domyn `Agent` instance. `agent_expose` is the Python module (i.e. `agent_expose.py`) and `agent` is the `Agent` instance exported from it. + +The process stays running and reconnects automatically on network drops. + +--- + +## Running with Docker (laptop or VM) + +Build the image: + +```bash +docker build -t domyn-agent-blueprint . +``` + +Run (reads credentials from `.env`): + +```bash +docker run --env-file .env domyn-agent-blueprint +``` + +Or with Docker Compose (one command): + +```bash +docker compose up +``` + +--- + +## Deploying to a VM + +1. Copy the blueprint directory to your VM: + +```bash +scp -r services/domyn_agent_example/ user@your-vm:/opt/domyn-agent/ +``` + +2. SSH into the VM and build: + +```bash +ssh user@your-vm +cd /opt/domyn-agent +cp .env.example .env && nano .env # fill in credentials +docker build -t domyn-agent-blueprint . +``` + +3. Run as a systemd service for automatic restart on reboot: + +```ini +# /etc/systemd/system/domyn-agent.service +[Unit] +Description=Domyn Native Agent Subagent +After=docker.service +Requires=docker.service + +[Service] +Restart=always +ExecStart=/usr/bin/docker run --rm --env-file /opt/domyn-agent/.env \ + --name domyn-agent domyn-agent-blueprint +ExecStop=/usr/bin/docker stop domyn-agent + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now domyn-agent +sudo journalctl -fu domyn-agent # tail logs +``` + +--- + +## Agent overview + +The agent is a domyn-agents ReAct agent backed by an OpenAI-compatible LLM. It receives a task from the platform orchestrator and reasons over a set of tools to produce a response. + +### Built-in tools + +| Tool | Description | +|---|---| +| `add_numbers` | Add two numbers | +| `multiply_numbers` | Multiply two numbers | +| `get_current_time` | Return the current UTC time | +| `reverse_string` | Reverse a string | +| `count_words` | Count words in a string | + +### Adding tools + +Open `agent_expose.py` and add a tool to the agent: + +```python +from domyn_agents.core.decorators import tool + +@tool(name="my_tool", description="Description shown to the LLM.") +def my_tool(x: str) -> str: + return x.upper() + +agent = Agent( + ... + tools=[..., my_tool], +) +``` + +### Changing the LLM + +Edit `_get_llm()` in `agent_expose.py` or set the env vars `VLLM_MODEL`, `VLLM_BASE_URL`, `VLLM_API_KEY`. + +The `OpenAIProvider` accepts any OpenAI-compatible endpoint (vLLM, Together, Groq, etc.). + +--- + +## How it works + +``` +Platform orchestrator + │ + │ AGENT_START (via WebSocket relay) + ▼ +domyn expose agent_expose:agent + │ + │ Receives AGENT_START, extracts task text + │ Runs domyn Runner with the Agent + │ + ▼ +Agent ReAct loop + │ + ├── LLM call (VLLM_MODEL via VLLM_BASE_URL) + ├── Tool execution (local) + └── RESPONSE → streamed back to platform +``` + +The `domyn expose` command auto-detects that `agent_expose:agent` is a domyn `Agent` instance and uses the `DomynAgentRuntime` — no input mapper or LangChain callbacks required. + +Multi-turn conversations are supported: the agent maintains conversation history per `conversation_id` across calls. diff --git a/services/domyn_agent_example/agent_expose.py b/services/domyn_agent_example/agent_expose.py new file mode 100644 index 0000000..a5058bd --- /dev/null +++ b/services/domyn_agent_example/agent_expose.py @@ -0,0 +1,130 @@ +import logging +import os +from datetime import UTC, datetime + +from domyn_agents.agents.agent import Agent +from domyn_agents.core.decorators import tool +from domyn_agents.llm.openai import OpenAIProvider +from domyn_agents.logger import set_logger +from domyn_agents.planner_strategy.tool_use import ToolUsePlannerStrategy +from domyn_agents.tools.delegate_tool import DelegateTool + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +set_logger(logging.getLogger("domyn_agents")) + +# --------------------------------------------------------------------------- +# LLM +# --------------------------------------------------------------------------- + + +def _get_llm() -> OpenAIProvider: + api_key = os.getenv("VLLM_API_KEY") or os.getenv("VLLM_API_KEY_DEFAULT", "") + base_url = os.getenv("VLLM_BASE_URL", "https://gateway-dev.llm.crystal.ai/v1") + # OpenAIProvider needs the full completions endpoint; accept base-URL style too. + if not base_url.endswith("/chat/completions"): + base_url = base_url.rstrip("/") + "/chat/completions" + return OpenAIProvider( + model_name=os.getenv("VLLM_MODEL", "Qwen/Qwen3-32B"), + url=base_url, + api_key=api_key, + generation_params={ + "temperature": 0.7, + "max_completion_tokens": 4000, + }, + ) + + +def _get_planner_with_stop() -> ToolUsePlannerStrategy: + return ToolUsePlannerStrategy(use_stop=True) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@tool(name="add_numbers", description="Add two numbers together.") +def add_numbers(a: float, b: float) -> float: + return a + b + + +@tool(name="multiply_numbers", description="Multiply two numbers together.") +def multiply_numbers(a: float, b: float) -> float: + return a * b + + +@tool(name="get_current_time", description="Return the current UTC time as an ISO 8601 string.") +def get_current_time() -> str: + return datetime.now(UTC).isoformat() + + +@tool(name="reverse_string", description="Reverse a string.") +def reverse_string(text: str) -> str: + return text[::-1] + + +@tool(name="count_words", description="Count the number of words in a string.") +def count_words(text: str) -> int: + return len(text.split()) + + +# --------------------------------------------------------------------------- +# Platform delegate tools +# Schema (parameters) is auto-fetched from the platform at expose-time by +# DomynAgentRuntime.initialize(); only the tool name is required here. +# --------------------------------------------------------------------------- + +web_search = DelegateTool(tool_name="web_search") + +# --------------------------------------------------------------------------- +# Agent +# --------------------------------------------------------------------------- + +math_agent = Agent( + name="MathAgent", + description="Specialist agent for arithmetic operations (addition, multiplication).", + instruction=( + "You are a math specialist. Use the available arithmetic tools to " + "compute the requested result and return it concisely." + ), + llm_provider=_get_llm(), + tools=[add_numbers, multiply_numbers], + planner=_get_planner_with_stop(), +) + +string_agent = Agent( + name="StringAgent", + description="Specialist agent for string operations (reverse, word count).", + instruction=( + "You are a string-processing specialist. Use the available tools to " + "transform or analyze the input text and return the result concisely." + ), + llm_provider=_get_llm(), + tools=[reverse_string, count_words], + planner=_get_planner_with_stop(), +) + +agent = Agent( + name="DomynAgent", + planner=ToolUsePlannerStrategy(), + description=( + "Orchestrator agent. Delegates arithmetic to MathAgent and string " + "operations to StringAgent; answers time-related questions and " + "web searches directly." + ), + instruction=( + "You are an orchestrator. For arithmetic questions delegate to " + "MathAgent. For string operations delegate to StringAgent. For " + "time-related questions use the get_current_time tool. " + "For questions that require current or external information use " + "the web_search tool. " + "If the user updates tool parameters mid-conversation, continue " + "with the new values." + ), + llm_provider=_get_llm(), + tools=[get_current_time, web_search], + sub_agents=[math_agent, string_agent], +) diff --git a/services/domyn_agent_example/docker-compose.yml b/services/domyn_agent_example/docker-compose.yml new file mode 100644 index 0000000..25be63a --- /dev/null +++ b/services/domyn_agent_example/docker-compose.yml @@ -0,0 +1,5 @@ +services: + domyn-agent: + build: . + env_file: .env + restart: unless-stopped diff --git a/services/domyn_agent_example/requirements.txt b/services/domyn_agent_example/requirements.txt new file mode 100644 index 0000000..cf4fe9b --- /dev/null +++ b/services/domyn_agent_example/requirements.txt @@ -0,0 +1,3 @@ +# Install domyn-agents from the bundled wheel in ./wheels/ +# Run: pip install wheels/domyn_agents-*.whl +# No additional dependencies required — domyn-agents is self-contained. diff --git a/services/domyn_agent_example/test_local.py b/services/domyn_agent_example/test_local.py new file mode 100644 index 0000000..0614444 --- /dev/null +++ b/services/domyn_agent_example/test_local.py @@ -0,0 +1,63 @@ +"""Quick local test — runs the agent directly without the WebSocket relay. + +Usage: + python test_local.py + python test_local.py "Add 5 and 7" +""" + +import asyncio +import sys + +from agent_expose import agent +from domyn_agents.core import BaseEvent, ExecutionEventType +from domyn_agents.runner import Runner + +CASES = [ + "Add 5 and 7", + "Multiply 3 by 9", + "What time is it?", + "Reverse the string 'hello world'", + "Count the words in: the quick brown fox", +] + + +async def run(task: str) -> None: + print(f"\nTask: {task!r}") + print("-" * 60) + + user_input = BaseEvent( + event_type=ExecutionEventType.USER_INPUT, + author="user", + content=[], + ) + # Inject task text via metadata so Runner builds correct user context + from domyn_agents.core import Part + + user_input = user_input.model_copy(update={"content": [Part(text=task)]}) + + runner = Runner() + async for event in runner.run( + application_name="test", + user_id="local-tester", + user_input=user_input, + root=agent, + ): + if event.event_type == ExecutionEventType.RESPONSE and not event.is_partial: + text = " ".join(p.text for p in (event.content or []) if p.text) + if text: + print(text) + elif event.event_type == ExecutionEventType.TOOL_START: + action = event.action + name = getattr(action, "name", "?") if action else "?" + params = getattr(action, "parameters", {}) if action else {} + print(f" [tool] {name}({params})") + + print("-" * 60) + + +if __name__ == "__main__": + if len(sys.argv) > 1: + asyncio.run(run(" ".join(sys.argv[1:]))) + else: + for case in CASES: + asyncio.run(run(case)) diff --git a/services/domyn_agent_example/wheels/.gitkeep b/services/domyn_agent_example/wheels/.gitkeep new file mode 100644 index 0000000..e69de29