Initial Agentsd project commit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:09:56 +08:00
commit 1bf1f08f9a
41 changed files with 8106 additions and 0 deletions

1
plugins/_sdk/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Agentsd Python SDK - shared gRPC stubs for plugins

210
plugins/_sdk/agentsd_pb2.py Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,222 @@
"""
AI Test Plugin for Agentsd
==========================
Manual verification plugin for AI agents and humans. It is intentionally
not autoloaded by default; run it only when testing Agentsd runtime behavior.
Usage:
# Start agentsd first:
# RUST_LOG=agentsd=info ./target/release/agentsd.exe
python plugins/ai_test/ai_test_plugin.py
"""
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from pathlib import Path
import grpc
ROOT = Path(__file__).resolve().parents[2]
SDK_DIR = ROOT / "plugins" / "_sdk"
sys.path.insert(0, str(SDK_DIR))
import agentsd_pb2 as pb # noqa: E402
import agentsd_pb2_grpc as rpc # noqa: E402
PLUGIN_ID = "ai-test"
METADATA = (("x-plugin-id", PLUGIN_ID),)
ADDR = "localhost:50051"
class CheckRunner:
def __init__(self):
self.channel = grpc.insecure_channel(ADDR)
self.bridge = rpc.PluginBridgeStub(self.channel)
self.display = rpc.DisplayStub(self.channel)
self.memory = rpc.MemoryStub(self.channel)
self.fs = rpc.FSStub(self.channel)
self.process = rpc.ProcessStub(self.channel)
self.timer = rpc.TimerStub(self.channel)
self.network = rpc.NetworkStub(self.channel)
self.results = []
self.run_id = str(int(time.time() * 1000))
self.scratch_dir = ROOT / "target" / "release" / "data" / "ai_test" / self.run_id
def close(self):
self.channel.close()
def record(self, name, ok, details=""):
icon = "PASS" if ok else "FAIL"
print(f"[{icon}] {name}: {details}")
self.results.append({"name": name, "ok": bool(ok), "details": details})
def check(self, name, fn):
try:
details = fn()
self.record(name, True, details or "ok")
except Exception as exc:
self.record(name, False, str(exc))
def register(self):
resp = self.bridge.Register(pb.PluginInfo(
id=PLUGIN_ID,
name="AI Test Plugin",
version="0.1.0",
plugin_type="basic",
syscalls=[
"display.text",
"memory.write", "memory.read", "memory.list", "memory.delete",
"fs.write", "fs.read", "fs.stat", "fs.list", "fs.rename", "fs.delete",
"process.spawn", "process.stdout", "process.wait",
"timer.once",
],
depends=[],
exports=[pb.ExportDef(name="run_checks", description="Run Agentsd verification checks")],
))
if not resp.ok:
raise RuntimeError(resp.error)
return f"session={resp.session_id}"
def display_text(self):
resp = self.display.Text(
pb.DisplayTextRequest(content=f"[ai-test] display check run={self.run_id}"),
metadata=METADATA,
)
if not resp.ok:
raise RuntimeError(resp.error)
return "display.text ok"
def memory_roundtrip(self):
key = f"ai_test.{self.run_id}.greeting"
value = f"hello-{self.run_id}".encode("utf-8")
resp = self.memory.Write(pb.MemoryWriteRequest(key=key, value=value), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"write: {resp.error}")
resp = self.memory.Read(pb.MemoryReadRequest(key=key), metadata=METADATA)
if not resp.found or resp.value != value:
raise RuntimeError(f"read mismatch found={resp.found} value={resp.value!r}")
resp = self.memory.List(pb.MemoryListRequest(prefix=f"ai_test.{self.run_id}", limit=10), metadata=METADATA)
if key not in resp.keys:
raise RuntimeError(f"list missing key; keys={list(resp.keys)} error={resp.error!r}")
resp = self.memory.Delete(pb.MemoryKeyRequest(key=key), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"delete: {resp.error}")
return f"key={key}"
def fs_roundtrip(self):
base = self.scratch_dir
file_a = base / "sample.txt"
file_b = base / "renamed.txt"
data = f"AI_TEST_FS_OK {self.run_id}".encode("utf-8")
resp = self.fs.Write(pb.FsWriteRequest(path=str(file_a), data=data, append=False), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"write: {resp.error}")
resp = self.fs.Read(pb.FsReadRequest(path=str(file_a), offset=0, length=0), metadata=METADATA)
if not resp.ok or resp.data != data:
raise RuntimeError(f"read mismatch ok={resp.ok} data={resp.data!r} error={resp.error!r}")
resp = self.fs.Stat(pb.FsPathRequest(path=str(file_a)), metadata=METADATA)
if not resp.ok or not resp.is_file or resp.size != len(data):
raise RuntimeError(f"stat mismatch ok={resp.ok} size={resp.size} error={resp.error!r}")
resp = self.fs.List(pb.FsPathRequest(path=str(base)), metadata=METADATA)
names = [e.name for e in resp.entries]
if not resp.ok or "sample.txt" not in names:
raise RuntimeError(f"list mismatch ok={resp.ok} names={names} error={resp.error!r}")
resp = self.fs.Rename(pb.FsRenameRequest(**{"from": str(file_a), "to": str(file_b)}), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"rename: {resp.error}")
resp = self.fs.Delete(pb.FsPathRequest(path=str(base)), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"delete: {resp.error}")
return str(base)
def process_roundtrip(self):
code = "print('AI_TEST_PROCESS_OK')"
resp = self.process.Spawn(pb.SpawnRequest(
cmd=sys.executable,
args=["-c", code],
env={},
cwd=str(ROOT),
), metadata=METADATA)
if not resp.ok:
raise RuntimeError(resp.error)
pid = resp.pid
chunks = []
for chunk in self.process.Stdout(pb.PidRequest(pid=pid), metadata=METADATA):
chunks.append(chunk.data)
output = b"".join(chunks).decode("utf-8", errors="replace").strip()
wait = self.process.Wait(pb.PidRequest(pid=pid), metadata=METADATA)
if not wait.ok or wait.exit_code != 0:
raise RuntimeError(f"wait failed ok={wait.ok} code={wait.exit_code} error={wait.error!r}")
if output != "AI_TEST_PROCESS_OK":
raise RuntimeError(f"stdout mismatch: {output!r}")
return f"pid={pid} stdout={output}"
def timer_event(self):
stream = self.bridge.StreamCall(
pb.CallRequest(target=PLUGIN_ID, fn="events", args=b""),
metadata=METADATA,
)
resp = self.timer.Once(pb.TimerOnceRequest(delay_ms=100, callback_id="ai-test-callback"), metadata=METADATA)
if not resp.ok:
raise RuntimeError(resp.error)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(next, stream)
try:
event = future.result(timeout=5)
except TimeoutError:
stream.cancel()
raise RuntimeError("timed out waiting for timer event")
payload = json.loads(event.result.decode("utf-8"))
if payload.get("event") != "timer" or payload.get("callback_id") != "ai-test-callback":
raise RuntimeError(f"unexpected payload: {payload}")
return json.dumps(payload, ensure_ascii=False)
def permission_denial(self):
try:
self.network.Available(pb.Empty(), metadata=METADATA)
except grpc.RpcError as exc:
if exc.code() == grpc.StatusCode.PERMISSION_DENIED:
return exc.details()
raise RuntimeError(f"wrong error: {exc.code().name} {exc.details()}")
raise RuntimeError("network.available unexpectedly succeeded")
def run(self):
print(f"AI Test Plugin connecting to {ADDR}")
print(f"Scratch dir: {self.scratch_dir}")
self.check("register", self.register)
self.check("display.text", self.display_text)
self.check("memory roundtrip", self.memory_roundtrip)
self.check("fs roundtrip", self.fs_roundtrip)
self.check("process roundtrip", self.process_roundtrip)
self.check("timer event", self.timer_event)
self.check("permission denial", self.permission_denial)
ok = all(r["ok"] for r in self.results)
summary = {"plugin": PLUGIN_ID, "ok": ok, "results": self.results}
print("AI_TEST_SUMMARY " + json.dumps(summary, ensure_ascii=False))
return 0 if ok else 1
def main():
runner = CheckRunner()
try:
return runner.run()
finally:
runner.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,31 @@
id: ai-test
name: AI Test Plugin
version: 0.1.0
type: basic
# Manual verification plugin. Future plugin loaders must skip it by default.
enabled: false
autoload: false
syscalls:
- display.text
- memory.write
- memory.read
- memory.list
- memory.delete
- fs.write
- fs.read
- fs.stat
- fs.list
- fs.rename
- fs.delete
- process.spawn
- process.stdout
- process.wait
- timer.once
exports:
- name: run_checks
description: Run end-to-end Agentsd syscall checks for AI verification
entry: python ai_test_plugin.py

View File

@@ -0,0 +1,312 @@
"""
Claude Code Plugin for Agentsd
==============================
Bridges locally installed Claude Code CLI into agentsd as a standard plugin.
Enables agentsd to use Claude Code for code generation, editing, and reasoning.
Architecture:
agentsd (Rust) ←gRPC→ claudecode_plugin.py → claude CLI (subprocess)
Capabilities:
- chat: Send a prompt to Claude Code, get response
- code: Generate/edit code with full Claude Code capabilities
- session: Resume previous Claude Code sessions
- tools: Let Claude Code use its built-in tools (Bash, Edit, Read, etc.)
Usage:
# Start agentsd first, then:
python claudecode_plugin.py
"""
import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from concurrent import futures
import grpc
# Add SDK path
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
sys.path.insert(0, str(SDK_DIR))
import agentsd_pb2 as pb
import agentsd_pb2_grpc as rpc
logging.basicConfig(level=logging.INFO, format="[claudecode] %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# Find claude CLI
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", None)
def find_claude():
"""Verify claude CLI is available and return the path."""
global CLAUDE_BIN
if CLAUDE_BIN is None:
# Auto-detect
candidates = [
"claude",
"claude.cmd",
os.path.expandvars(r"%APPDATA%\npm\claude.cmd"),
os.path.expanduser("~/.local/bin/claude"),
"/usr/local/bin/claude",
]
for c in candidates:
try:
result = subprocess.run(
[c, "--version"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
CLAUDE_BIN = c
break
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if CLAUDE_BIN is None:
logger.error("Claude Code CLI not found")
logger.error("Install: npm install -g @anthropic-ai/claude-code")
return None
try:
result = subprocess.run(
[CLAUDE_BIN, "--version"],
capture_output=True, text=True, timeout=10
)
version = result.stdout.strip()
logger.info("Found Claude Code: %s at %s", version, CLAUDE_BIN)
return version
except Exception as e:
logger.error("Claude Code CLI error: %s", e)
return None
class ClaudeCodePlugin:
"""Bridges Claude Code CLI into agentsd."""
def __init__(self, agentsd_addr: str = "localhost:50051"):
self.addr = agentsd_addr
self.channel = None
self.bridge = None
self.version = None
self.default_cwd = os.getcwd()
def connect(self):
"""Connect to agentsd."""
self.channel = grpc.insecure_channel(self.addr)
self.bridge = rpc.PluginBridgeStub(self.channel)
logger.info("Connected to agentsd at %s", self.addr)
def register(self):
"""Register with agentsd."""
resp = self.bridge.Register(pb.PluginInfo(
id="claudecode",
name="Claude Code",
version=self.version or "unknown",
plugin_type="standard",
syscalls=["process.spawn", "display.text", "fs.read", "fs.write", "network.request"],
depends=["hermes"],
exports=[
pb.ExportDef(name="chat", description="Send prompt to Claude Code, get text response"),
pb.ExportDef(name="code", description="Generate or edit code with Claude Code"),
pb.ExportDef(name="review", description="Review code with Claude Code"),
pb.ExportDef(name="ask", description="Ask Claude Code a question about the codebase"),
pb.ExportDef(name="run", description="Run Claude Code with full tool access"),
],
))
if resp.ok:
logger.info("Registered with agentsd: session=%s", resp.session_id)
else:
logger.error("Registration failed: %s", resp.error)
return resp.ok
def call_claude(self, prompt: str, cwd: str = None, max_turns: int = None,
allowed_tools: list = None, system_prompt: str = None,
output_format: str = "text") -> dict:
"""
Call Claude Code CLI in print mode.
Args:
prompt: The prompt to send
cwd: Working directory for Claude Code
max_turns: Max agentic turns (default: unlimited)
allowed_tools: List of tools to allow (e.g. ["Bash", "Read", "Edit"])
system_prompt: Override system prompt
output_format: "text" or "json" or "stream-json"
Returns:
dict with "result" or "error"
"""
cmd = [CLAUDE_BIN, "-p", "--output-format", output_format]
if max_turns is not None:
cmd.extend(["--max-turns", str(max_turns)])
if allowed_tools:
cmd.extend(["--allowedTools"] + allowed_tools)
if system_prompt:
cmd.extend(["--system-prompt", system_prompt])
work_dir = cwd or self.default_cwd
logger.info("Calling claude: cwd=%s prompt=%s...", work_dir, prompt[:60])
try:
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
cwd=work_dir,
timeout=300, # 5 min max
env={**os.environ, "CLAUDE_CODE_SIMPLE": "1"},
)
if result.returncode == 0:
output = result.stdout.strip()
if output_format == "json":
try:
return json.loads(output)
except json.JSONDecodeError:
return {"result": output}
return {"result": output}
else:
error = result.stderr.strip() or f"exit code {result.returncode}"
return {"error": error}
except subprocess.TimeoutExpired:
return {"error": "timeout (300s)"}
except Exception as e:
return {"error": str(e)}
def handle_call(self, fn_name: str, args: dict) -> dict:
"""Route a call to the appropriate handler."""
prompt = args.get("prompt", args.get("text", ""))
cwd = args.get("cwd", self.default_cwd)
if fn_name == "chat":
return self.call_claude(
prompt=prompt,
cwd=cwd,
max_turns=1, # Single turn, no tools
allowed_tools=[],
output_format="text",
)
elif fn_name == "code":
return self.call_claude(
prompt=prompt,
cwd=cwd,
allowed_tools=["Bash", "Read", "Edit", "Write", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "review":
review_prompt = f"Review the following code or changes. Be concise and actionable:\n\n{prompt}"
return self.call_claude(
prompt=review_prompt,
cwd=cwd,
max_turns=1,
allowed_tools=["Read", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "ask":
return self.call_claude(
prompt=prompt,
cwd=cwd,
max_turns=3,
allowed_tools=["Read", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "run":
# Full access mode
return self.call_claude(
prompt=prompt,
cwd=cwd,
output_format="text",
)
else:
return {"error": f"unknown function: {fn_name}"}
def run_server(self, port: int = 50053):
"""Run callback gRPC server for agentsd."""
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
rpc.add_PluginBridgeServicer_to_server(ClaudeCodeServicer(self), server)
server.add_insecure_port(f"[::]:{port}")
server.start()
logger.info("Claude Code plugin server listening on port %d", port)
return server
class ClaudeCodeServicer(rpc.PluginBridgeServicer):
"""gRPC servicer for Claude Code plugin."""
def __init__(self, plugin: ClaudeCodePlugin):
self.plugin = plugin
def Call(self, request, context):
fn_name = request.fn
try:
args = json.loads(request.args) if request.args else {}
except json.JSONDecodeError:
args = {}
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
result = self.plugin.handle_call(fn_name, args)
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
if "error" in result and not result.get("result"):
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
return pb.CallResponse(result=result_bytes, ok=True, error="")
def Register(self, request, context):
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return pb.RegisterResponse(ok=False, error="not a bridge")
def Heartbeat(self, request, context):
return pb.HeartbeatResponse(ok=True)
def StreamCall(self, request, context):
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return pb.CallResponse(ok=False, error="not implemented")
def main():
# Verify claude CLI
version = find_claude()
if not version:
sys.exit(1)
plugin = ClaudeCodePlugin()
plugin.version = version
# Connect and register
plugin.connect()
if not plugin.register():
sys.exit(1)
# Start callback server
server = plugin.run_server(port=50053)
logger.info("Claude Code plugin running (v%s). Exports: chat, code, review, ask, run", version)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down...")
server.stop(grace=5)
plugin.channel.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
id: claudecode
name: Claude Code
version: 2.1.169
type: standard
syscalls:
- process.spawn
- display.text
- fs.read
- fs.write
- network.request
depends:
- hermes # 可选,用于 agent 编排
exports:
- name: chat
description: Send prompt to Claude Code, get text response (no tools)
- name: code
description: Generate or edit code with Claude Code (Bash/Read/Edit/Write)
- name: review
description: Review code changes (Read/Glob/Grep only)
- name: ask
description: Ask about the codebase (Read/Glob/Grep, max 3 turns)
- name: run
description: Full Claude Code access (all tools, unlimited turns)
entry: python claudecode_plugin.py
port: 50053

View File

@@ -0,0 +1,29 @@
"""Quick test: call Claude Code plugin's chat export."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "_sdk"))
import grpc
import agentsd_pb2 as pb
import agentsd_pb2_grpc as rpc
def main():
# Connect to claudecode plugin's callback server
channel = grpc.insecure_channel("localhost:50053")
bridge = rpc.PluginBridgeStub(channel)
# Call "chat"
args = json.dumps({"prompt": "What is 2+2? Reply with just the number."}).encode()
resp = bridge.Call(pb.CallRequest(target="claudecode", fn="chat", args=args))
print(f"ok={resp.ok}")
if resp.result:
result = json.loads(resp.result)
print(f"result: {result.get('result', result.get('error', ''))}")
channel.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
"""
Echo plugin for agentsd - verifies the gRPC syscall chain works.
Registers as a basic plugin, calls Display.Text and FS.Read.
"""
import grpc
import sys
import os
# Add SDK path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_sdk"))
from agentsd_pb2 import (
DisplayTextRequest, FsReadRequest, FsPathRequest,
MemoryWriteRequest, MemoryReadRequest,
PluginInfo, ExportDef, Empty
)
from agentsd_pb2_grpc import (
DisplayStub, FSStub, MemoryStub, PluginBridgeStub
)
PLUGIN_ID = "echo-test"
METADATA = (("x-plugin-id", PLUGIN_ID),)
def main():
channel = grpc.insecure_channel('localhost:50051')
# Register plugin
bridge = PluginBridgeStub(channel)
resp = bridge.Register(PluginInfo(
id=PLUGIN_ID,
name="Echo Test Plugin",
version="0.1.0",
plugin_type="basic",
syscalls=["display.text", "fs.list", "memory.read", "memory.write"],
depends=[],
exports=[ExportDef(name="echo", description="Echo test")]
))
print(f"Register: ok={resp.ok} session={resp.session_id}")
# Test Display
display = DisplayStub(channel)
resp = display.Text(DisplayTextRequest(content="Hello from echo plugin!"), metadata=METADATA)
print(f"Display.Text: ok={resp.ok}")
# Test Memory write + read
memory = MemoryStub(channel)
resp = memory.Write(MemoryWriteRequest(key="test.greeting", value=b"Hello World"), metadata=METADATA)
print(f"Memory.Write: ok={resp.ok}")
resp = memory.Read(MemoryReadRequest(key="test.greeting"), metadata=METADATA)
print(f"Memory.Read: found={resp.found} value={resp.value.decode()}")
# Test FS list
fs = FSStub(channel)
resp = fs.List(FsPathRequest(path="."), metadata=METADATA)
print(f"FS.List: ok={resp.ok} entries={len(resp.entries)}")
for entry in resp.entries[:5]:
print(f" {entry.name} {'[dir]' if entry.is_dir else ''}")
print("\nAll syscalls verified OK!")
channel.close()
if __name__ == "__main__":
main()

16
plugins/echo/plugin.yaml Normal file
View File

@@ -0,0 +1,16 @@
id: echo-test
name: Echo Test Plugin
version: 0.1.0
type: basic
syscalls:
- display.text
- fs.list
- memory.read
- memory.write
exports:
- name: echo
description: Echo test
entry: python echo_plugin.py

View File

@@ -0,0 +1,215 @@
"""
Hermes Plugin for Agentsd
=========================
Bridges Hermes Agent's tool registry into agentsd as a standard plugin.
All Hermes tools become callable via agentsd's gRPC PluginBridge.
Architecture:
agentsd (Rust) ←gRPC→ hermes-plugin.py ← Hermes tools/registry.py
This plugin:
1. Imports Hermes tool registry and discovers all built-in tools
2. Registers with agentsd as a "basic" plugin, exporting all Hermes tools
3. Listens for Call requests from agentsd and dispatches to Hermes handlers
"""
import asyncio
import json
import logging
import os
import sys
import threading
import time
from concurrent import futures
from pathlib import Path
import grpc
from grpc import StatusCode
# Add hermes-agent to path
HERMES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "hermes-agent"
sys.path.insert(0, str(HERMES_DIR))
# Import generated proto
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
sys.path.insert(0, str(SDK_DIR))
import agentsd_pb2 as pb
import agentsd_pb2_grpc as rpc
logging.basicConfig(level=logging.INFO, format="[hermes-plugin] %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
class HermesPlugin:
"""Bridges Hermes tools into agentsd."""
def __init__(self, agentsd_addr: str = "localhost:50051"):
self.addr = agentsd_addr
self.channel = None
self.bridge = None
self.registry = None
self.tool_handlers = {}
def load_hermes_tools(self):
"""Import and discover all Hermes built-in tools."""
logger.info("Loading Hermes tools from: %s", HERMES_DIR)
# Import registry
from tools.registry import registry, discover_builtin_tools
# Discover and import all tool modules
imported = discover_builtin_tools(HERMES_DIR / "tools")
logger.info("Imported %d tool modules", len(imported))
self.registry = registry
# Collect all tool names and handlers
all_names = registry.get_all_tool_names()
for name in all_names:
entry = registry.get_entry(name)
if entry:
self.tool_handlers[name] = entry
logger.info("Loaded %d tools: %s", len(self.tool_handlers), list(self.tool_handlers.keys())[:10])
return all_names
def connect(self):
"""Connect to agentsd gRPC server."""
self.channel = grpc.insecure_channel(self.addr)
self.bridge = rpc.PluginBridgeStub(self.channel)
logger.info("Connected to agentsd at %s", self.addr)
def register(self, tool_names):
"""Register this plugin with agentsd, exporting all Hermes tools."""
exports = []
for name in tool_names:
entry = self.tool_handlers.get(name)
desc = entry.description if entry else ""
exports.append(pb.ExportDef(name=name, description=desc[:200]))
resp = self.bridge.Register(pb.PluginInfo(
id="hermes",
name="Hermes Agent Tools",
version="0.16.0",
plugin_type="basic",
syscalls=["network.request", "fs.read", "fs.write", "process.spawn", "memory.read", "memory.write", "display.text", "timer.once"],
depends=[],
exports=exports,
))
if resp.ok:
logger.info("Registered with agentsd: session=%s, %d exports", resp.session_id, len(exports))
else:
logger.error("Registration failed: %s", resp.error)
return resp.ok
def call_tool(self, tool_name: str, args: dict) -> dict:
"""Execute a Hermes tool by name with given args."""
entry = self.tool_handlers.get(tool_name)
if not entry:
return {"error": f"tool not found: {tool_name}"}
try:
if entry.is_async:
# Run async handler in event loop
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(entry.handler(args))
finally:
loop.close()
else:
result = entry.handler(args)
# Hermes tools return strings or dicts
if isinstance(result, str):
return {"result": result}
elif isinstance(result, dict):
return result
else:
return {"result": str(result)}
except Exception as e:
logger.error("Tool %s failed: %s", tool_name, e)
return {"error": str(e)}
def run_server(self, port: int = 50052):
"""
Run a gRPC server that agentsd can call back into.
Agentsd routes Call requests here for Hermes tools.
"""
from concurrent import futures
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
rpc.add_PluginBridgeServicer_to_server(HermesServicer(self), server)
server.add_insecure_port(f"[::]:{port}")
server.start()
logger.info("Hermes plugin server listening on port %d", port)
return server
class HermesServicer(rpc.PluginBridgeServicer):
"""gRPC servicer that handles tool call requests from agentsd."""
def __init__(self, plugin: HermesPlugin):
self.plugin = plugin
def Call(self, request, context):
"""Handle a tool call from agentsd."""
fn_name = request.fn
try:
args = json.loads(request.args) if request.args else {}
except json.JSONDecodeError:
args = {}
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
result = self.plugin.call_tool(fn_name, args)
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
if "error" in result and not result.get("result"):
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
return pb.CallResponse(result=result_bytes, ok=True, error="")
def Register(self, request, context):
context.set_code(StatusCode.UNIMPLEMENTED)
return pb.RegisterResponse(ok=False, error="not a bridge")
def Heartbeat(self, request, context):
return pb.HeartbeatResponse(ok=True)
def StreamCall(self, request, context):
context.set_code(StatusCode.UNIMPLEMENTED)
return pb.CallResponse(ok=False, error="not implemented")
def main():
plugin = HermesPlugin()
# Load all Hermes tools
try:
tool_names = plugin.load_hermes_tools()
except Exception as e:
logger.error("Failed to load Hermes tools: %s", e)
logger.info("Ensure hermes-agent is at: %s", HERMES_DIR)
sys.exit(1)
# Connect and register with agentsd
plugin.connect()
if not plugin.register(tool_names):
sys.exit(1)
# Start callback server for tool invocations
server = plugin.run_server(port=50052)
logger.info("Hermes plugin running. %d tools available. Ctrl+C to stop.", len(tool_names))
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down...")
server.stop(grace=5)
plugin.channel.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,19 @@
id: hermes
name: Hermes Agent Tools
version: 0.16.0
type: basic
syscalls:
- network.request
- fs.read
- fs.write
- process.spawn
- memory.read
- memory.write
- display.text
- timer.once
exports: auto # 自动从 Hermes registry 发现
entry: python hermes_plugin.py
port: 50052