Initial commit: hermes-swarm skill

This commit is contained in:
Danials 2026-06-15 12:22:54 +00:00
commit 30e3d05bec
9 changed files with 713 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
.DS_Store
__pycache__/
*.py[cod]
*$py.class
*.so
.env
.venv/
venv/
swarm_outputs/
*.log
.idea/
.vscode/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hermes Swarm contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

60
README.md Normal file
View file

@ -0,0 +1,60 @@
# hermes-swarm
Multi-agent discussion swarm for Hermes Agent. Replace one expensive reasoning model with several cheap models that argue, refine, and converge.
## What it does
- Runs a round-robin discussion between Hermes profiles via Redis Pub/Sub.
- Each agent sees the full transcript before replying.
- First agent synthesizes the final answer.
- Saves transcript and final answer as Markdown.
## Install
```bash
git clone https://forgejo.redtask.ru/youruser/hermes-swarm.git
cd hermes-swarm
```
Install dependency:
```bash
pip install redis
```
## Quick start
1. Start Redis:
```bash
docker compose -f templates/docker-compose.redis.yml up -d
```
2. Add Hermes profiles (see `templates/hermes-profiles.yaml`).
3. Run:
```bash
python3 scripts/swarm_chat.py \
--topic "Refactor this function to use async SQLAlchemy" \
--agents swarm-architect swarm-critic swarm-coder \
--rounds 3
```
## Environment variables
| Variable | Default |
|----------|---------|
| `REDIS_URL` | `redis://localhost:***@dataclass | `SWARM_OUTPUT_DIR` | `./swarm_outputs` |
| `HERMES_WORKDIR` | `.` |
| `HERMES_CMD` | `hermes` |
## Example use cases
- **Code review**: feed a diff, let critic + reviewer + coder discuss.
- **Design decisions**: compare two approaches with architect + critic.
- **Test planning**: let tester + coder + architect write a test strategy.
## License
MIT

160
SKILL.md Normal file
View file

@ -0,0 +1,160 @@
---
name: hermes-swarm
description: "Run a multi-agent discussion swarm using cheap models via Hermes CLI and Redis Pub/Sub."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [swarm, multi-agent, delegation, redis, collaboration, reasoning, cheap-models]
related_skills: [subagent-driven-development, writing-plans, plan]
---
# hermes-swarm
Use this skill when you want **several cheap models to discuss a problem and converge on a solution** instead of paying for one expensive reasoning model.
## When to use
- The task benefits from multiple perspectives (architect, critic, coder, tester).
- You have access to several small/cheap models via Hermes profiles.
- You want a reusable, self-hosted collaboration mechanism with no cloud orchestrator.
- You can run Redis locally or in Docker.
## When NOT to use
- One strong model is cheaper than 3-4 small calls (always benchmark cost).
- The task is trivial or one-shot.
- You need real-time synchronous chat between agents — this is round-robin, not live chat.
## What you get
```
┌─────────────────────────────────────────────┐
│ Redis Pub/Sub room │
│ myproject:swarm:<random>
└──────────────┬──────────────────────────────┘
┌───────────┼───────────┐
▼ ▼ ▼
swarm- swarm- swarm-
architect critic coder
│ │ │
└───────────┴───────────┘
[synthesizer] → final.md + transcript.md
```
## Prerequisites
1. **Hermes Agent** installed and `hermes` in `$PATH`.
2. **Redis** running locally or reachable via `REDIS_URL`.
3. At least **two Hermes profiles** configured with different models/roles.
## Quick start
### 1. Install Redis (Docker)
```bash
docker run -d --name redis-swarm \
-p 6379:6379 \
redis:7-alpine
```
Or use the included `templates/docker-compose.redis.yml`.
### 2. Create Hermes profiles
Add a block like this to `~/.hermes/config.yaml` for each agent role:
```yaml
profiles:
swarm-architect:
provider: openrouter # or any provider you use
model: google/gemma-3-12b-it:cheap
system_prompt: |
You are the architect in a multi-agent engineering discussion.
Look at the big picture, propose structure and design trade-offs.
swarm-critic:
provider: openrouter
model: deepseek/deepseek-v3:free
system_prompt: |
You are the critic. Challenge assumptions, find flaws and risks.
swarm-coder:
provider: openrouter
model: qwen/qwen-2.5-coder-32b-instruct
system_prompt: |
You are the coder. Turn ideas into concrete, working code.
```
> Tip: keep these profiles cheap. The whole point is to replace one expensive call with several cheap ones.
### 3. Run the swarm
```bash
cd /path/to/hermes-swarm
python3 scripts/swarm_chat.py \
--topic "Design a Python LRU cache for HTTP responses" \
--agents swarm-architect swarm-critic swarm-coder \
--rounds 3 \
--room myproject:swarm:lru_cache
```
Output files land in `./swarm_outputs/` by default:
- `<room>_final.md` — synthesized answer
- `<room>_transcript.md` — full discussion
### 4. Tune environment variables
| Variable | Default | Meaning |
|----------|---------|---------|
| `REDIS_URL` | `redis://localhost:***@dataclass | `SWARM_OUTPUT_DIR` | `./swarm_outputs` | Where transcripts/final answers are saved |
| `HERMES_WORKDIR` | current directory | Working dir passed to each Hermes agent |
| `HERMES_CMD` | `hermes` | Hermes CLI binary |
## Architecture
The dispatcher runs **synchronously** in rounds:
1. **Seed** the Redis room with the topic and agent list.
2. For each round, ask every agent to read the full history and reply.
3. After the last round, ask the first agent (synthesizer) to write the final answer.
4. Persist final answer and transcript to disk.
All messages are stored in a Redis sorted set (`room:history`), so agents can read the full context even if they are restarted.
## Extending roles
Edit `scripts/swarm_chat.py` or pass custom role labels with `--roles`. Built-in roles:
- `architect` — structure and trade-offs
- `critic` — flaws, edge cases, risks
- `coder` — concrete code
- `tester` — tests and verification
- `reviewer` — clarity and completeness
- `planner` — actionable steps
## Cost tips
- Start with `--rounds 2` and 2-3 agents to measure token usage.
- Use the cheapest models that understand your domain.
- Compare cost vs. a single strong model on the same task.
- Set `SWARM_OUTPUT_DIR` to your project folder so outputs become project artifacts.
## Files
```
hermes-swarm/
├── SKILL.md # this file
├── README.md # installation guide
├── scripts/
│ └── swarm_chat.py # dispatcher
├── templates/
│ ├── docker-compose.redis.yml # Redis setup
│ └── hermes-profiles.yaml # example profile config
└── examples/
├── code-review.sh # review a PR diff
└── design-decision.sh # compare two approaches
```

26
examples/code-review.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Example: use the swarm to review a PR diff.
cd "$(dirname "$0")/.."
TOPIC=$(cat <<'EOF'
Review the following PR diff for correctness, performance, and style.
Only discuss the diff; do not invent unrelated improvements.
EOF
)
# Append the actual diff, e.g. from stdin or a file
if [ -p /dev/stdin ]; then
TOPIC="${TOPIC}$(cat)"
else
echo "Usage: cat diff.patch | ./examples/code-review.sh"
exit 1
fi
python3 scripts/swarm_chat.py \
--topic "$TOPIC" \
--agents swarm-critic swarm-reviewer swarm-coder \
--roles critic reviewer coder \
--rounds 2 \
--room project:swarm:code_review

11
examples/design-decision.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Example: use the swarm to decide between two approaches.
cd "$(dirname "$0")/.."
python3 scripts/swarm_chat.py \
--topic "Should we use SQLAlchemy 2.0 style or 1.x style in our FastAPI project? Compare maintainability, async support, and migration cost." \
--agents swarm-architect swarm-critic swarm-coder \
--roles architect critic coder \
--rounds 3 \
--room project:swarm:design_decision

360
scripts/swarm_chat.py Normal file
View file

@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
Hermes Swarm Chat reusable multi-agent discussion over Redis Pub/Sub.
Usage:
python3 swarm_chat.py \
--topic "Design a Python function that caches HTTP responses" \
--agents architect critic coder \
--rounds 3 \
--room myproject:swarm:demo
Each agent is a separate Hermes profile. The dispatcher:
1. seeds a Redis room with the topic,
2. asks every agent in turn to read room history and respond,
3. limits discussion to N rounds,
4. asks the first agent (synthesizer) to write the final answer.
Environment:
REDIS_URL Redis URI (default: redis://localhost:6379/0)
HERMES_CMD Hermes binary (default: hermes)
HERMES_WORKDIR Working directory passed to hermes chat (default: current dir)
SWARM_OUTPUT_DIR Where to save transcripts/final answer (default: ./swarm_outputs)
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
import redis
DEFAULT_ROOM_PREFIX = "hermes:swarm"
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
HERMES_CMD = os.getenv("HERMES_CMD", "hermes")
HERMES_WORKDIR = os.getenv("HERMES_WORKDIR", os.getcwd())
OUTPUT_DIR = Path(os.getenv("SWARM_OUTPUT_DIR", "./swarm_outputs")).resolve()
@dataclass
class SwarmMessage:
id: str
round: int
agent: str
role: str
content: str
timestamp: float = field(default_factory=time.time)
reply_to: Optional[str] = None
def to_json(self) -> str:
return json.dumps({
"id": self.id,
"round": self.round,
"agent": self.agent,
"role": self.role,
"content": self.content,
"timestamp": self.timestamp,
"reply_to": self.reply_to,
}, ensure_ascii=False, default=str)
@classmethod
def from_dict(cls, d: dict) -> "SwarmMessage":
return cls(
id=d["id"],
round=d["round"],
agent=d["agent"],
role=d["role"],
content=d["content"],
timestamp=d.get("timestamp", time.time()),
reply_to=d.get("reply_to"),
)
@dataclass
class AgentDef:
name: str # Hermes profile name, e.g. swarm-architect
role: str # human-readable role, e.g. architect
prompt: str # system role instruction
model: str = "" # informational only
class RedisRoom:
def __init__(self, room: str, redis_url: str = REDIS_URL):
self.room = room
self.control_key = f"{room}:control"
self.r = redis.from_url(redis_url, decode_responses=True)
self.pubsub = self.r.pubsub()
self.pubsub.subscribe(room)
def post(self, msg: SwarmMessage) -> None:
self.r.publish(self.room, msg.to_json())
# Also keep durable history sorted by timestamp in a Redis sorted set.
self.r.zadd(f"{self.room}:history", {msg.to_json(): msg.timestamp})
def history(self, since: float = 0) -> List[SwarmMessage]:
items = self.r.zrangebyscore(f"{self.room}:history", since, "+inf")
messages = []
for raw in items:
try:
messages.append(SwarmMessage.from_dict(json.loads(raw)))
except Exception:
continue
return messages
def clear(self) -> None:
self.r.delete(f"{self.room}:history")
self.r.delete(self.control_key)
def signal_stop(self, reason: str = "rounds_exhausted") -> None:
self.r.set(self.control_key, json.dumps({"stop": True, "reason": reason}))
def should_stop(self) -> bool:
raw = self.r.get(self.control_key)
if isinstance(raw, str) and raw:
try:
return json.loads(raw).get("stop", False)
except Exception:
pass
return False
class HermesAgentRunner:
"""Runs a single Hermes agent via CLI and captures the text reply."""
def __init__(self, profile: str, timeout: int = 120, workdir: str = HERMES_WORKDIR, hermes_cmd: str = HERMES_CMD):
self.profile = profile
self.timeout = timeout
self.workdir = workdir
self.hermes_cmd = hermes_cmd
def ask(self, prompt: str) -> str:
cmd = [
self.hermes_cmd, "-p", self.profile, "chat",
"-q", prompt,
"--source", "swarm",
"-Q", # quiet: suppress banner/spinner
]
env = os.environ.copy()
# Force working directory so agents see consistent paths.
env["HERMES_CWD"] = self.workdir
try:
result = subprocess.run(
cmd,
cwd=self.workdir,
env=env,
capture_output=True,
text=True,
timeout=self.timeout,
)
output = result.stdout + result.stderr
# Strip ANSI escape codes.
output = re.sub(r"\x1b\[[0-9;]*m", "", output)
return self._extract_answer(output)
except subprocess.TimeoutExpired:
return f"[timeout: agent {self.profile} did not respond within {self.timeout}s]"
except Exception as e:
return f"[error running agent {self.profile}: {e}]"
@staticmethod
def _extract_answer(output: str) -> str:
lines = output.splitlines()
drop_prefixes = (
"Thinking", "Using model", "", "", "", "", "",
"You:", "Hermes:", "", "", "Running", "Tool result",
)
cleaned = []
for line in lines:
stripped = line.strip()
if not stripped:
continue
stripped = re.sub(r"\x1b\[[0-9;]*m", "", stripped)
if any(stripped.startswith(p) for p in drop_prefixes):
continue
if "session_id:" in stripped or "⚠ tirith" in stripped:
continue
cleaned.append(line)
while cleaned and not cleaned[-1].strip():
cleaned.pop()
text = "\n".join(cleaned).strip()
if not text:
text = re.sub(r"\x1b\[[0-9;]*m", "", output).strip()
return text
class SwarmChat:
def __init__(self, room: RedisRoom, agents: List[AgentDef], topic: str, rounds: int = 3):
self.room = room
self.agents = agents
self.topic = topic
self.rounds = rounds
self.runner = HermesAgentRunner(profile="") # profile set per turn
def _build_prompt(self, agent: AgentDef, round_no: int, history: List[SwarmMessage]) -> str:
transcript = []
for m in history:
transcript.append(f"[Round {m.round} | {m.agent} ({m.role})]\n{m.content}\n")
transcript_text = "\n".join(transcript) if transcript else "(no messages yet)"
return (
f"You are participating in a multi-agent engineering discussion.\n\n"
f"YOUR ROLE: {agent.role}\n"
f"{agent.prompt}\n\n"
f"DISCUSSION TOPIC:\n{self.topic}\n\n"
f"CURRENT ROUND: {round_no} of {self.rounds}\n\n"
f"PREVIOUS MESSAGES IN THIS ROOM:\n{transcript_text}\n\n"
f"TASK:\n"
f"Read the previous messages, then contribute your perspective as the {agent.role}. "
f"Be concise (2-5 sentences). Do not repeat what others already said. "
f"If you disagree, explain why and propose a better approach. "
f"If the discussion has already converged, say so and add any final detail."
)
def run(self) -> List[SwarmMessage]:
seed = SwarmMessage(
id=f"seed-{uuid.uuid4().hex[:8]}",
round=0,
agent="dispatcher",
role="moderator",
content=f"Discussion topic: {self.topic}. Agents: {[a.role for a in self.agents]}. Maximum rounds: {self.rounds}.",
)
self.room.post(seed)
for r in range(1, self.rounds + 1):
if self.room.should_stop():
print(f"[dispatcher] stop signal detected before round {r}")
break
print(f"\n=== Round {r}/{self.rounds} ===")
for agent in self.agents:
if self.room.should_stop():
break
history = self.room.history(since=0)
prompt = self._build_prompt(agent, r, history)
self.runner.profile = agent.name
print(f"[ask {agent.name} ({agent.role})] ...", end="", flush=True)
answer = self.runner.ask(prompt)
print(f" ({len(answer)} chars)")
msg = SwarmMessage(
id=f"r{r}-{agent.role}-{uuid.uuid4().hex[:6]}",
round=r,
agent=agent.name,
role=agent.role,
content=answer,
)
self.room.post(msg)
self.room.signal_stop("rounds_exhausted")
return self.room.history(since=0)
def synthesize(self) -> str:
history = self.room.history(since=0)
transcript = []
for m in history:
if m.agent == "dispatcher":
continue
transcript.append(f"[{m.agent} ({m.role}), round {m.round}]\n{m.content}\n")
transcript_text = "\n".join(transcript)
synthesizer = self.agents[0]
prompt = (
f"You are the {synthesizer.role}. The multi-agent discussion below has finished.\n\n"
f"ORIGINAL TOPIC:\n{self.topic}\n\n"
f"FULL TRANSCRIPT:\n{transcript_text}\n\n"
f"TASK:\n"
f"Write a concise final answer (1-3 paragraphs) that incorporates the best ideas, resolves disagreements, "
f"and gives the user a concrete, actionable result. Do not introduce fictional tools or APIs."
)
self.runner.profile = synthesizer.name
print(f"\n[synthesize via {synthesizer.name}] ...", end="", flush=True)
answer = self.runner.ask(prompt)
print(f" ({len(answer)} chars)")
return answer
def main():
parser = argparse.ArgumentParser(description="Run a synchronous multi-agent discussion via Redis Pub/Sub.")
parser.add_argument("--topic", required=True, help="The discussion topic / task.")
parser.add_argument("--agents", nargs="+", required=True,
help="Hermes profile names to use as agents, e.g. swarm-architect swarm-critic swarm-coder")
parser.add_argument("--roles", nargs="+", default=None,
help="Optional role labels matching --agents. If omitted, derived from profile names.")
parser.add_argument("--rounds", type=int, default=3, help="Number of discussion rounds (default 3).")
parser.add_argument("--room", default=None, help="Redis room key. Default: hermes:swarm:<random>.")
parser.add_argument("--no-synth", action="store_true", help="Skip final synthesis.")
parser.add_argument("--clear", action="store_true", help="Clear room history before starting.")
parser.add_argument("--timeout", type=int, default=120, help="Timeout per agent call in seconds.")
parser.add_argument("--output-dir", type=Path, default=None,
help="Directory for transcripts/final output. Default: SWARM_OUTPUT_DIR or ./swarm_outputs")
args = parser.parse_args()
if len(args.agents) < 2:
print("Need at least 2 agents for a discussion.", file=sys.stderr)
sys.exit(1)
room_name = args.room or f"{DEFAULT_ROOM_PREFIX}:{uuid.uuid4().hex[:8]}"
room = RedisRoom(room_name)
if args.clear:
room.clear()
roles = args.roles or [a.replace("swarm-", "") for a in args.agents]
if len(roles) != len(args.agents):
print("--roles must match --agents length.", file=sys.stderr)
sys.exit(1)
role_prompts = {
"architect": "Look at the big picture, propose structure and design trade-offs.",
"critic": "Challenge assumptions, find flaws, edge cases, and risks in others' proposals.",
"coder": "Turn ideas into concrete code or commands. Prefer working examples.",
"tester": "Identify what needs to be tested, write test cases, and verify edge cases.",
"reviewer": "Check the proposed solution for clarity, completeness, and maintainability.",
"planner": "Break the topic into actionable steps and suggest an execution order.",
"default": "Contribute relevant expertise and help the group converge on a good answer.",
}
agents = []
for profile, role in zip(args.agents, roles):
agents.append(AgentDef(
name=profile,
role=role,
prompt=role_prompts.get(role, role_prompts["default"]),
))
out_dir = args.output_dir or OUTPUT_DIR
out_dir.mkdir(parents=True, exist_ok=True)
print(f"Room: {room_name}")
print(f"Agents: {[a.name + '(' + a.role + ')' for a in agents]}")
print(f"Rounds: {args.rounds}")
print(f"Output: {out_dir}")
swarm = SwarmChat(room, agents, args.topic, args.rounds)
swarm.runner.timeout = args.timeout
history = swarm.run()
print(f"\nTotal messages in room: {len(history)}")
if not args.no_synth:
final = swarm.synthesize()
print("\n=== FINAL SYNTHESIS ===")
print(final)
out_file = out_dir / f"{room_name.replace(':', '_')}_final.md"
out_file.write_text(f"# {args.topic}\n\n{final}\n", encoding="utf-8")
print(f"\nSaved synthesis to {out_file}")
tx_file = out_dir / f"{room_name.replace(':', '_')}_transcript.md"
lines = [f"# {args.topic}\n\n## Room: {room_name}\n\n"]
for m in history:
lines.append(f"### Round {m.round}{m.agent} ({m.role})\n\n{m.content}\n\n")
tx_file.write_text("".join(lines), encoding="utf-8")
print(f"Saved transcript to {tx_file}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,15 @@
version: "3.8"
services:
redis-swarm:
image: redis:7-alpine
container_name: redis-swarm
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
command: redis-server --appendonly yes
volumes:
- redis-swarm-data:/data
volumes:
redis-swarm-data:

View file

@ -0,0 +1,48 @@
# Example Hermes profiles for the swarm skill.
# Copy relevant blocks into ~/.hermes/config.yaml under the `profiles:` key.
profiles:
swarm-architect:
provider: openrouter
model: google/gemma-3-12b-it:cheap
system_prompt: |
You are the architect in a multi-agent engineering discussion.
Look at the big picture, propose structure and design trade-offs,
and help the group converge on a clean, maintainable design.
Be concise (2-5 sentences per reply).
swarm-critic:
provider: openrouter
model: deepseek/deepseek-v3:free
system_prompt: |
You are the critic in a multi-agent engineering discussion.
Challenge assumptions, find flaws, edge cases, security risks,
and non-obvious downsides of every proposal.
Be concise (2-5 sentences per reply).
swarm-coder:
provider: openrouter
model: qwen/qwen-2.5-coder-32b-instruct
system_prompt: |
You are the coder in a multi-agent engineering discussion.
Turn ideas into concrete, working code or commands.
Prefer minimal, readable examples that compile/run.
Be concise (2-5 sentences + short code blocks).
swarm-tester:
provider: openrouter
model: qwen/qwen-2.5-coder-32b-instruct
system_prompt: |
You are the tester in a multi-agent engineering discussion.
Identify what needs to be tested, write test cases,
and verify edge cases and failure modes.
Be concise (2-5 sentences per reply).
swarm-reviewer:
provider: openrouter
model: google/gemma-3-12b-it:cheap
system_prompt: |
You are the reviewer in a multi-agent engineering discussion.
Check the proposed solution for clarity, completeness,
maintainability, and consistency with the original problem.
Be concise (2-5 sentences per reply).