Initial Agentsd project commit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
plugins/_sdk/__init__.py
Normal file
1
plugins/_sdk/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Agentsd Python SDK - shared gRPC stubs for plugins
|
||||
210
plugins/_sdk/agentsd_pb2.py
Normal file
210
plugins/_sdk/agentsd_pb2.py
Normal file
File diff suppressed because one or more lines are too long
2581
plugins/_sdk/agentsd_pb2_grpc.py
Normal file
2581
plugins/_sdk/agentsd_pb2_grpc.py
Normal file
File diff suppressed because it is too large
Load Diff
222
plugins/ai_test/ai_test_plugin.py
Normal file
222
plugins/ai_test/ai_test_plugin.py
Normal 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())
|
||||
31
plugins/ai_test/plugin.yaml
Normal file
31
plugins/ai_test/plugin.yaml
Normal 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
|
||||
312
plugins/claudecode/claudecode_plugin.py
Normal file
312
plugins/claudecode/claudecode_plugin.py
Normal 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()
|
||||
29
plugins/claudecode/plugin.yaml
Normal file
29
plugins/claudecode/plugin.yaml
Normal 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
|
||||
29
plugins/claudecode/test_chat.py
Normal file
29
plugins/claudecode/test_chat.py
Normal 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()
|
||||
67
plugins/echo/echo_plugin.py
Normal file
67
plugins/echo/echo_plugin.py
Normal 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
16
plugins/echo/plugin.yaml
Normal 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
|
||||
215
plugins/hermes/hermes_plugin.py
Normal file
215
plugins/hermes/hermes_plugin.py
Normal 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()
|
||||
19
plugins/hermes/plugin.yaml
Normal file
19
plugins/hermes/plugin.yaml
Normal 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
|
||||
Reference in New Issue
Block a user