diff --git a/examples/collaborative-agents/.gitignore b/examples/collaborative-agents/.gitignore new file mode 100644 index 00000000..9204635c --- /dev/null +++ b/examples/collaborative-agents/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc \ No newline at end of file diff --git a/examples/collaborative-agents/README.md b/examples/collaborative-agents/README.md new file mode 100644 index 00000000..0aa20ac9 --- /dev/null +++ b/examples/collaborative-agents/README.md @@ -0,0 +1,243 @@ +# Bindu Collaborative Agents + +A working multi-agent system demonstrating the **Internet of Agents** using the Bindu framework. + +Three specialized agents collaborate to answer questions — each with its own DID identity, +communicating over the A2A protocol. + +--- + +## System Architecture + +``` +User Query + ↓ +Coordinator Agent (port 3775) + ↓ ↓ +Memory Agent Research Agent +(port 3774) (port 3773) + ↓ ↓ +Semantic DuckDuckGo +Retrieval Web Search +``` + +**Flow:** +1. User sends query to Coordinator +2. Coordinator checks Memory Agent (semantic similarity search) +3. If found in memory → return instantly +4. If not found → Research Agent searches the web +5. Answer stored in Memory Agent for future queries +6. Response returned to user + +--- + +## Agents + +### Research Agent (port 3773) +Searches the web using DuckDuckGo and answers questions using an LLM via OpenRouter. +Specialized in the Bindu AI framework and Internet of Agents concepts. + +### Memory Agent (port 3774) +Stores and retrieves knowledge using vector embeddings and cosine similarity. +Uses `text-embedding-3-small` via OpenRouter. Threshold of 0.75 similarity +ensures only relevant memories are returned. + +Commands: +- `store:` — stores text in semantic memory +- `retrieve:` — retrieves most similar memory + +### Coordinator Agent (port 3775) +Orchestrates the other two agents. Checks memory first, falls back to research, +stores new knowledge automatically. Each call uses the A2A `message/send` protocol. + +--- + +## Prerequisites + +- Python 3.12+ +- OpenRouter API key (free tier works): https://openrouter.ai + +--- + +## Installation + +```bash +git clone https://github.com/Subhajitdas99/bindu-collaborative-agents.git +cd bindu-collaborative-agents +pip install -r requirements.txt +``` + +Set your API key: + +```bash +# Linux/macOS +export OPENROUTER_API_KEY="your-api-key" + +# Windows PowerShell +$env:OPENROUTER_API_KEY="your-api-key" +``` + +Or create a `.env` file: + +``` +OPENROUTER_API_KEY=your-api-key +BINDU_AUTHOR=your.email@example.com +``` + +--- + +## Running + +Open **3 separate terminals** and run one agent in each: + +**Terminal 1 — Research Agent** +```bash +python research_agent.py +``` + +**Terminal 2 — Memory Agent** +```bash +python memory_agent.py +``` + +**Terminal 3 — Coordinator Agent** +```bash +python coordinator_agent.py +``` + +--- + +## Testing + +Send a query to the coordinator (Terminal 4): + +**Linux/macOS:** +```bash +curl -X POST http://localhost:3775/ \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "What is Bindu?"}], + "kind": "message", + "messageId": "11111111-1111-1111-1111-111111111111", + "contextId": "11111111-1111-1111-1111-111111111112", + "taskId": "11111111-1111-1111-1111-111111111113" + }, + "configuration": {"acceptedOutputModes": ["application/json"]} + }, + "id": "11111111-1111-1111-1111-111111111114" + }' +``` + +**Windows PowerShell:** +```powershell +Invoke-WebRequest -Uri "http://localhost:3775/" ` + -Method POST ` + -ContentType "application/json" ` + -UseBasicParsing ` + -Body '{"jsonrpc":"2.0","method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"What is Bindu?"}],"kind":"message","messageId":"11111111-1111-1111-1111-111111111111","contextId":"11111111-1111-1111-1111-111111111112","taskId":"11111111-1111-1111-1111-111111111113"},"configuration":{"acceptedOutputModes":["application/json"]}},"id":"11111111-1111-1111-1111-111111111114"}' | Select-Object -ExpandProperty Content +``` + +Wait 15-20 seconds (research takes time), then poll for the result: + +**Linux/macOS:** +```bash +curl -X POST http://localhost:3775/ \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "tasks/get", + "params": {"taskId": "11111111-1111-1111-1111-111111111113"}, + "id": "11111111-1111-1111-1111-111111111115" + }' +``` + +**Windows PowerShell:** +```powershell +Invoke-WebRequest -Uri "http://localhost:3775/" ` + -Method POST ` + -ContentType "application/json" ` + -UseBasicParsing ` + -Body '{"jsonrpc":"2.0","method":"tasks/get","params":{"taskId":"11111111-1111-1111-1111-111111111113"},"id":"11111111-1111-1111-1111-111111111115"}' | Select-Object -ExpandProperty Content +``` + +--- + +## Expected Output + +First query (cache miss — researches the web): +```json +{ + "result": { + "status": {"state": "completed"}, + "artifacts": [{ + "parts": [{ + "kind": "text", + "text": "The Bindu AI Framework is designed to facilitate the creation of + intelligent and interoperable AI agents..." + }] + }] + } +} +``` + +Second identical query (cache hit — served from memory instantly): +```json +{ + "result": { + "artifacts": [{ + "parts": [{"kind": "text", "text": "(From Memory) The Bindu AI Framework..."}] + }] + } +} +``` + +--- + +## What This Demonstrates + +- **Agent-to-Agent (A2A) communication** over HTTP using the Bindu protocol +- **DID identity** — each agent gets a unique Decentralized Identifier +- **Semantic memory** — knowledge retrieved by meaning, not keyword matching +- **Coordinator pattern** — orchestration without tight coupling +- **Internet of Agents** — agents discovering and calling each other + +--- + +## Project Structure + +``` +collaborative-agents/ +├── coordinator_agent.py — orchestrates research + memory agents +├── research_agent.py — web search via DuckDuckGo +├── memory_agent.py — semantic memory store/retrieve +├── requirements.txt +├── .env — API keys (not committed) +├── .gitignore +└── utils/ + └── semantic_memory.py — embeddings + cosine similarity +``` + +--- + +## Technologies + +- [Bindu](https://github.com/getbindu/bindu) — Internet of Agents framework +- [Agno](https://github.com/agno-agi/agno) — agent framework +- [OpenRouter](https://openrouter.ai) — LLM + embeddings API +- [DuckDuckGo](https://pypi.org/project/duckduckgo-search/) — web search +- NumPy — cosine similarity computation + +--- + +## Author + +**Subhajit Das** +Final Year B.Tech AI/ML Student +Interested in Multi-Agent Systems, AI Infrastructure, and Autonomous Agents. + +GitHub: [@Subhajitdas99](https://github.com/Subhajitdas99) \ No newline at end of file diff --git a/examples/collaborative-agents/coordinator_agent.py b/examples/collaborative-agents/coordinator_agent.py new file mode 100644 index 00000000..c5bfd1e7 --- /dev/null +++ b/examples/collaborative-agents/coordinator_agent.py @@ -0,0 +1,117 @@ +"""Coordinator agent — orchestrates research and memory agents.""" +from dotenv import load_dotenv +load_dotenv() +import os +import time + +import httpx +from bindu.penguin.bindufy import bindufy + +MEMORY_AGENT_URL = os.getenv("MEMORY_AGENT_URL", "http://localhost:3774") +RESEARCH_AGENT_URL = os.getenv("RESEARCH_AGENT_URL", "http://localhost:3773") + +import uuid + + +def call_agent(url: str, message: str) -> str: + """Send a message to another Bindu agent, poll until complete, return result.""" + msg_id = str(uuid.uuid4()) + ctx_id = str(uuid.uuid4()) + task_id = str(uuid.uuid4()) + rpc_id = str(uuid.uuid4()) + + payload = { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": message}], + "kind": "message", + "messageId": msg_id, + "contextId": ctx_id, + "taskId": task_id, + }, + "configuration": {"acceptedOutputModes": ["application/json"]}, + }, + "id": rpc_id, + } + + # Send the message + response = httpx.post(url, json=payload, timeout=30) + result = response.json() + actual_task_id = result.get("result", {}).get("id") + if not actual_task_id: + return "No response from agent" + + # Poll until completed + for _ in range(30): + time.sleep(1) + poll_payload = { + "jsonrpc": "2.0", + "method": "tasks/get", + "params": {"taskId": actual_task_id}, + "id": str(uuid.uuid4()), + } + poll_response = httpx.post(url, json=poll_payload, timeout=10) + poll_result = poll_response.json().get("result", {}) + state = poll_result.get("status", {}).get("state") + + if state == "completed": + artifacts = poll_result.get("artifacts", []) + if artifacts: + parts = artifacts[0].get("parts", []) + if parts: + text = parts[0].get("text", "") + # Don't return error responses + if text and "No response from agent" not in text: + return text + return "No response from agent" + + return "Agent timed out" + + +def handler(messages: list[dict]) -> str: + """Coordinate memory and research agents to answer a query.""" + query = messages[-1]["content"] + + # 1. Try memory first + memory_result = call_agent(MEMORY_AGENT_URL, f"retrieve:{query}") + if memory_result and memory_result not in ( + "No relevant memory found.", + "No response from agent", + "Agent timed out", + ): + print("Retrieved from memory.") + return f"(From Memory) {memory_result}" + + # 2. Fall back to research + print("Researching...") + research_result = call_agent(RESEARCH_AGENT_URL, query) + + # 3. Store only valid results + if research_result and research_result not in ( + "No response from agent", + "Agent timed out", + ): + call_agent(MEMORY_AGENT_URL, f"store:{research_result}") + + return research_result + + +config = { + "author": os.getenv("BINDU_AUTHOR", "your.email@example.com"), + "name": "coordinator_agent", + "description": ( + "Coordinates research and memory agents to answer questions " + "about the Bindu framework." + ), + "deployment": { + "url": os.getenv("BINDU_DEPLOYMENT_URL", "http://localhost:3775"), + "expose": True, + }, + "skills": [], +} + +if __name__ == "__main__": + bindufy(config, handler) \ No newline at end of file diff --git a/examples/collaborative-agents/memory_agent.py b/examples/collaborative-agents/memory_agent.py new file mode 100644 index 00000000..24955019 --- /dev/null +++ b/examples/collaborative-agents/memory_agent.py @@ -0,0 +1,46 @@ +"""Memory agent — stores and retrieves knowledge using semantic similarity.""" +from dotenv import load_dotenv +load_dotenv() +import os + +from bindu.penguin.bindufy import bindufy +from utils.semantic_memory import retrieve_memory, store_memory + + +def handler(messages: list[dict]) -> str: + """Handle store or retrieve memory requests. + + Message format: + store: — stores text in memory + retrieve: — retrieves similar memories + """ + content = messages[-1]["content"] + + if content.startswith("store:"): + text = content[len("store:"):].strip() + store_memory(text) + return f"Stored in memory: {text[:80]}..." + + elif content.startswith("retrieve:"): + query = content[len("retrieve:"):].strip() + results = retrieve_memory(query) + if results: + return results[0]["text"] + return "No relevant memory found." + + return "Unknown command. Use store: or retrieve:." + + +config = { + "author": os.getenv("BINDU_AUTHOR", "your.email@example.com"), + "name": "memory_agent", + "description": "Stores and retrieves knowledge using semantic similarity.", + "deployment": { + "url": os.getenv("BINDU_DEPLOYMENT_URL", "http://localhost:3774"), + "expose": True, + }, + "skills": [], +} + +if __name__ == "__main__": + bindufy(config, handler) \ No newline at end of file diff --git a/examples/collaborative-agents/requirements.txt b/examples/collaborative-agents/requirements.txt new file mode 100644 index 00000000..b72fdccf --- /dev/null +++ b/examples/collaborative-agents/requirements.txt @@ -0,0 +1,7 @@ +bindu +agno +openai +numpy +httpx +python-dotenv + diff --git a/examples/collaborative-agents/research_agent.py b/examples/collaborative-agents/research_agent.py new file mode 100644 index 00000000..822b50c3 --- /dev/null +++ b/examples/collaborative-agents/research_agent.py @@ -0,0 +1,52 @@ +"""Research agent — searches the web and answers questions about Bindu.""" +from dotenv import load_dotenv +load_dotenv() +import os + +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from agno.tools.duckduckgo import DuckDuckGoTools +from bindu.penguin.bindufy import bindufy + +model = OpenAIChat( + id="openai/gpt-4o-mini", + base_url="https://openrouter.ai/api/v1", + api_key=os.getenv("OPENROUTER_API_KEY"), +) + +research_agent = Agent( + instructions=""" +You are a research agent specialized in the Bindu AI Framework. +If the user asks about Bindu, assume they mean the Bindu AI framework +used for building interoperable AI agents. +Search and explain concepts like: +- Internet of Agents +- Bindu Framework +- Agent-to-Agent communication +- Bindu architecture +""", + model=model, + tools=[DuckDuckGoTools()], +) + + +def handler(messages: list[dict]) -> str: + """Handle incoming messages and return research results.""" + query = messages[-1]["content"] + result = research_agent.run(input=query) + return result.content + + +config = { + "author": os.getenv("BINDU_AUTHOR", "your.email@example.com"), + "name": "research_agent", + "description": "Searches the web and answers questions using DuckDuckGo.", + "deployment": { + "url": os.getenv("BINDU_DEPLOYMENT_URL", "http://localhost:3773"), + "expose": True, + }, + "skills": [], +} + +if __name__ == "__main__": + bindufy(config, handler) \ No newline at end of file diff --git a/examples/collaborative-agents/utils/semantic_memory.py b/examples/collaborative-agents/utils/semantic_memory.py new file mode 100644 index 00000000..da1a4bd7 --- /dev/null +++ b/examples/collaborative-agents/utils/semantic_memory.py @@ -0,0 +1,71 @@ +import numpy as np +import os +from openai import OpenAI + +# Configure OpenRouter client +client = OpenAI( + api_key=os.getenv("OPENROUTER_API_KEY"), + base_url="https://openrouter.ai/api/v1" +) + +memory_store = [] + + +def get_embedding(text: str): + response = client.embeddings.create( + model="text-embedding-3-small", + input=text + ) + return response.data[0].embedding + + +def cosine_similarity(v1, v2): + v1 = np.array(v1) + v2 = np.array(v2) + return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) + + +def store_memory(text: str): + embedding = get_embedding(text) + + memory_store.append({ + "text": text, + "embedding": embedding + }) + + print(f"📚 Stored memory: {text[:80]}...") + + +def retrieve_memory(query: str, top_k: int = 1): + + if not memory_store: + return [] + + query_embedding = get_embedding(query) + + scores = [] + + for item in memory_store: + similarity = cosine_similarity(query_embedding, item["embedding"]) + + scores.append({ + "text": item["text"], + "score": similarity + }) + + scores = sorted(scores, key=lambda x: x["score"], reverse=True) + + # Only return if similarity is strong + SIMILARITY_THRESHOLD = 0.75 + results = [s for s in scores if s["score"] >= SIMILARITY_THRESHOLD] + + # Prefer most recent stored memory + results = sorted( + results, + key=lambda x: memory_store.index( + next(item for item in memory_store if item["text"] == x["text"]) + ), + reverse=True + ) + + return results[:top_k]