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

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Rust build output
/target/
# Runtime data (created next to the executable)
/data/
**/data/
*.db
*.sqlite
*.sqlite3
plugins.json
# Python cache / virtual environments
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
# Logs and local env files
*.log
.env
.env.*
# Editor / OS files
.vscode/
.idea/
.DS_Store
Thumbs.db

1763
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[workspace]
resolver = "2"
members = ["core/agentsd", "core/proto"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tonic = "0.12"
prost = "0.13"
tonic-build = "0.12"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
notify = "7"

84
README.md Normal file
View File

@@ -0,0 +1,84 @@
# Agentsd
AI Agent 的 init 系统 + 内核。对标 Linux 内核 + systemd。
> 状态:设计草案
## 对标
| Linux | Agentsd | 说明 |
|---|---|---|
| 内核 syscall 表 | 9 个 syscall 接口 | 进程与硬件之间的抽象层 |
| 设备驱动 (.ko) | 系统插件 | 实现 syscall(如 alsa = audio 驱动) |
| systemd unit | 插件(basic / standard) | 声明依赖、由 init 管理生命周期 |
| systemd(PID 1) | agentsd 进程 | 启动排序、依赖解析、看门狗、日志 |
| D-Bus | gRPC over TCP(后续切 UDS/Named Pipe) | 进程间通信总线 |
| /dev | syscall 接口 | 插件访问能力的统一入口 |
| cgroup / seccomp | 权限白名单 | 访问控制 |
## 架构
```
标准插件 ← 对标 systemd 管理的应用服务(nginx、docker...)
基础插件 ← 对标 systemd 管理的基础服务(udevd、resolved...)
系统插件 ← 对标 Linux 内核模块 / 设备驱动(.ko)
───────────────────────────────────────
agentsd ← 对标 Linux 内核 + systemd(PID 1)
```
## 9 个系统调用
| # | syscall | Linux 类比 |
|---|---|---|
| 1 | Display | write(stdout) / framebuffer |
| 2 | Audio | /dev/snd (ALSA) |
| 3 | FS | open / read / write / stat / inotify |
| 4 | Memory | mmap / shmem(面向结构化数据) |
| 5 | Network | socket / connect / send / recv |
| 6 | Process | fork / exec / wait / kill |
| 7 | Timer | timer_create / timerfd |
| 8 | HID | /dev/input + /dev/fb |
| 9 | Input | read(stdin) / evdev |
## 三种插件
| 类型 | Linux 类比 | 通信 | 依赖 |
|---|---|---|---|
| 系统插件 | 内核模块 / 驱动 | FFI | 无 |
| 基础插件 | 基础 daemon | gRPC/TCP(后续 UDS/Named Pipe) | 只 syscall |
| 标准插件 | 应用服务 | gRPC/TCP(后续 UDS/Named Pipe) | syscall + 其他插件 |
## agentsd 作为 PID 1 的职责
| 职责 | systemd 对标 |
|---|---|
| 启动排序 | unit ordering (After/Before) |
| 看门狗 | Restart=always |
| 依赖解析 | Requires / Wants |
| 日志收集 | journald |
| 资源控制 | cgroup + seccomp |
| 状态查询 | systemctl status |
| 热更新 | systemctl reload |
## 文档
| 文档 | 内容 |
|---|---|
| [01-内核系统调用.md](./docs/01-内核系统调用.md) | 9 个 syscall 定义(= 内核 syscall 表) |
| [02-插件模型.md](./docs/02-插件模型.md) | 三层插件 + gRPC + 权限(= 驱动 + unit + D-Bus) |
| [03-实施路线.md](./docs/03-实施路线.md) | 阶段进度 |
| [04-项目结构.md](./docs/04-项目结构.md) | 代码目录 + 构建说明 |
## 当前状态
阶段 1 已完成:内核骨架可运行,9 个 syscall gRPC service + PluginBridge 已注册,Python 测试插件验证全链路通过。默认运行数据写入程序所在目录的 `data/` 文件夹。`plugins/ai_test` 提供手动 AI 验证插件,默认不开启/不自动加载。Release 二进制约 6.1MB。
## 手动验证
```bash
cargo build --release
RUST_LOG=agentsd=info ./target/release/agentsd.exe
python plugins/ai_test/ai_test_plugin.py
```
`plugins/ai_test/plugin.yaml` 中设置了 `enabled: false``autoload: false`,未来实现插件自动加载时应默认跳过它。

23
core/agentsd/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "agentsd"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "agentsd"
path = "src/main.rs"
[dependencies]
agentsd-proto = { path = "../proto" }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tonic = { workspace = true }
prost = { workspace = true }
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
uuid = { workspace = true }
notify = { workspace = true }

View File

@@ -0,0 +1,47 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::sync::mpsc;
/// A fired timer event delivered to the plugin that created it.
#[derive(Clone, Debug)]
pub struct TimerEvent {
pub timer_id: String,
pub callback_id: String,
}
/// Manages per-plugin event channels for timer callbacks.
pub struct CallbackBus {
senders: Mutex<HashMap<String, mpsc::Sender<TimerEvent>>>,
}
impl CallbackBus {
pub fn new() -> Self {
Self {
senders: Mutex::new(HashMap::new()),
}
}
/// Register a plugin and get a receiver for its timer events.
pub fn subscribe(&self, plugin_id: &str) -> mpsc::Receiver<TimerEvent> {
let (tx, rx) = mpsc::channel(64);
self.senders
.lock()
.unwrap()
.insert(plugin_id.to_string(), tx);
rx
}
/// Deliver a timer event to the target plugin.
pub fn fire(&self, plugin_id: &str, event: TimerEvent) {
let senders = self.senders.lock().unwrap();
if let Some(tx) = senders.get(plugin_id) {
// Non-blocking send; drop if channel full
let _ = tx.try_send(event);
}
}
/// Remove a plugin's channel (on unregister/disconnect).
pub fn remove(&self, plugin_id: &str) {
self.senders.lock().unwrap().remove(plugin_id);
}
}

22
core/agentsd/src/main.rs Normal file
View File

@@ -0,0 +1,22 @@
mod syscall;
mod plugin;
mod server;
pub mod paths;
pub mod callback;
pub mod permission;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("agentsd=info".parse()?))
.init();
tracing::info!("agentsd starting");
tracing::info!("data dir: {}", paths::data_dir().display());
let kernel = syscall::Kernel::new()?;
server::run(kernel).await
}

12
core/agentsd/src/paths.rs Normal file
View File

@@ -0,0 +1,12 @@
use std::path::PathBuf;
/// Returns the `data/` directory next to the running executable.
/// Creates it if it does not exist.
pub fn data_dir() -> PathBuf {
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("."));
let dir = exe.parent().unwrap_or_else(|| std::path::Path::new(".")).join("data");
if !dir.exists() {
std::fs::create_dir_all(&dir).ok();
}
dir
}

View File

@@ -0,0 +1,82 @@
use crate::plugin::{PluginManager, PluginRecord, PluginStatus};
use std::sync::Arc;
use tonic::{Request, Status};
/// Extract plugin_id from gRPC metadata.
pub fn plugin_id_from_metadata(req_metadata: &tonic::metadata::MetadataMap) -> Option<String> {
req_metadata
.get("x-plugin-id")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
/// Load a plugin and require it to be currently running.
pub fn require_running_plugin(
plugins: &Arc<PluginManager>,
plugin_id: &str,
) -> Result<PluginRecord, Status> {
let record = plugins.get(plugin_id).ok_or_else(|| {
Status::permission_denied(format!("unknown plugin: {}", plugin_id))
})?;
if record.status != PluginStatus::Running {
return Err(Status::permission_denied(format!(
"plugin '{}' is not running",
plugin_id
)));
}
Ok(record)
}
/// Verify that the caller has permission to call an exact syscall name,
/// e.g. `fs.list`, `memory.write`, `timer.once`.
///
/// Supported grants:
/// - `*` grants everything
/// - `fs` grants all fs syscalls
/// - `fs.*` grants all fs syscalls
/// - `fs.list` grants only that method
pub fn check_permission(
plugins: &Arc<PluginManager>,
req_metadata: &tonic::metadata::MetadataMap,
syscall_name: &str,
) -> Result<String, Status> {
let Some(plugin_id) = plugin_id_from_metadata(req_metadata) else {
// Direct/internal/manual testing call.
return Ok(String::new());
};
let record = require_running_plugin(plugins, &plugin_id)?;
// System plugins have unrestricted access.
if record.plugin_type == "system" {
return Ok(plugin_id);
}
let service_name = syscall_name.split('.').next().unwrap_or(syscall_name);
let service_wildcard = format!("{}.*", service_name);
let allowed = record.syscalls.iter().any(|grant| {
grant == "*" || grant == syscall_name || grant == service_name || grant == &service_wildcard
});
if !allowed {
return Err(Status::permission_denied(format!(
"plugin '{}' has no permission for syscall '{}'",
plugin_id, syscall_name
)));
}
Ok(plugin_id)
}
/// Helper to extract plugin_id and check permission in one call within a service method.
pub fn guard<T>(
plugins: &Arc<PluginManager>,
req: &Request<T>,
syscall_name: &str,
) -> Result<String, Status> {
check_permission(plugins, req.metadata(), syscall_name)
}

139
core/agentsd/src/plugin.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::collections::HashMap;
use std::sync::Mutex;
/// Plugin registry: tracks loaded plugins and their exports.
/// Persists state to `<exe_dir>/data/plugins.json`.
pub struct PluginManager {
plugins: Mutex<HashMap<String, PluginRecord>>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PluginRecord {
pub id: String,
pub name: String,
pub version: String,
pub plugin_type: String, // "system" | "basic" | "standard"
pub syscalls: Vec<String>,
pub depends: Vec<String>,
pub exports: Vec<(String, String)>, // (name, description)
pub status: PluginStatus,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PluginStatus {
Registered,
Running,
Stopped,
Failed(String),
}
impl PluginManager {
pub fn new() -> Self {
let mut mgr = Self {
plugins: Mutex::new(HashMap::new()),
};
mgr.load_from_disk();
mgr
}
pub fn register(&self, record: PluginRecord) -> Result<(), String> {
let mut plugins = self.plugins.lock().unwrap();
if plugins.contains_key(&record.id) {
tracing::info!(
"plugin re-registered: id={} type={} exports={:?}",
record.id,
record.plugin_type,
record.exports.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
plugins.insert(record.id.clone(), record);
drop(plugins);
self.save_to_disk();
return Ok(());
}
tracing::info!(
"plugin registered: id={} type={} exports={:?}",
record.id,
record.plugin_type,
record.exports.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
plugins.insert(record.id.clone(), record);
drop(plugins);
self.save_to_disk();
Ok(())
}
pub fn get(&self, id: &str) -> Option<PluginRecord> {
self.plugins.lock().unwrap().get(id).cloned()
}
pub fn list(&self) -> Vec<PluginRecord> {
self.plugins.lock().unwrap().values().cloned().collect()
}
pub fn set_status(&self, id: &str, status: PluginStatus) {
let mut plugins = self.plugins.lock().unwrap();
if let Some(record) = plugins.get_mut(id) {
record.status = status;
}
drop(plugins);
self.save_to_disk();
}
pub fn find_export(&self, target: &str, fn_name: &str) -> Option<String> {
let plugins = self.plugins.lock().unwrap();
if let Some(record) = plugins.get(target) {
if record.exports.iter().any(|(n, _)| n == fn_name) {
return Some(target.to_string());
}
}
// Search all plugins for the export
for record in plugins.values() {
if record.exports.iter().any(|(n, _)| n == fn_name) {
return Some(record.id.clone());
}
}
None
}
pub fn unregister(&self, id: &str) -> Result<(), String> {
let mut plugins = self.plugins.lock().unwrap();
plugins
.remove(id)
.map(|_| ())
.ok_or_else(|| format!("plugin '{}' not found", id))?;
drop(plugins);
self.save_to_disk();
Ok(())
}
// --- Persistence ---
fn state_path() -> std::path::PathBuf {
crate::paths::data_dir().join("plugins.json")
}
fn save_to_disk(&self) {
let plugins = self.plugins.lock().unwrap();
let records: Vec<&PluginRecord> = plugins.values().collect();
if let Ok(json) = serde_json::to_string_pretty(&records) {
let _ = std::fs::write(Self::state_path(), json);
}
}
fn load_from_disk(&mut self) {
let path = Self::state_path();
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(records) = serde_json::from_str::<Vec<PluginRecord>>(&data) {
let mut plugins = self.plugins.lock().unwrap();
for mut record in records {
// Mark previously-running plugins as stopped on reload
if record.status == PluginStatus::Running {
record.status = PluginStatus::Stopped;
}
plugins.insert(record.id.clone(), record);
}
tracing::info!("loaded {} plugins from {}", plugins.len(), path.display());
}
}
}
}

706
core/agentsd/src/server.rs Normal file
View File

@@ -0,0 +1,706 @@
use crate::permission;
use crate::plugin::{PluginManager, PluginRecord, PluginStatus};
use crate::syscall::Kernel;
use agentsd_proto::agentsd::*;
use agentsd_proto::agentsd::{
audio_server::Audio as AudioService, display_server::Display as DisplayService,
fs_server::Fs as FsService, hid_server::Hid as HidService,
input_server::Input as InputService, memory_server::Memory as MemoryService,
network_server::Network as NetworkService,
plugin_bridge_server::PluginBridge as PluginBridgeService,
process_server::Process as ProcessService, timer_server::Timer as TimerService,
};
use std::sync::Arc;
use tonic::{Request, Response, Status};
pub struct AgentsdServer {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
impl AgentsdServer {
fn new(kernel: Kernel) -> Self {
Self {
kernel: Arc::new(kernel),
plugins: Arc::new(PluginManager::new()),
}
}
}
fn auth<T>(plugins: &Arc<PluginManager>, req: &Request<T>, syscall: &str) -> Result<String, Status> {
permission::guard(plugins, req, syscall)
}
// ===== Display =====
pub struct DisplaySvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl DisplayService for DisplaySvc {
async fn text(&self, req: Request<DisplayTextRequest>) -> Result<Response<DisplayResponse>, Status> {
auth(&self.plugins, &req, "display.text")?;
let r = req.into_inner();
match self.kernel.display.text(&r.content) {
Ok(()) => Ok(Response::new(DisplayResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(DisplayResponse { ok: false, error: e })),
}
}
async fn rich(&self, req: Request<DisplayRichRequest>) -> Result<Response<DisplayResponse>, Status> {
auth(&self.plugins, &req, "display.rich")?;
let r = req.into_inner();
match self.kernel.display.rich(&r.markup, &r.format) {
Ok(()) => Ok(Response::new(DisplayResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(DisplayResponse { ok: false, error: e })),
}
}
async fn image(&self, req: Request<DisplayImageRequest>) -> Result<Response<DisplayResponse>, Status> {
auth(&self.plugins, &req, "display.image")?;
let r = req.into_inner();
match self.kernel.display.image(&r.data, &r.format) {
Ok(()) => Ok(Response::new(DisplayResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(DisplayResponse { ok: false, error: e })),
}
}
async fn clear(&self, req: Request<Empty>) -> Result<Response<DisplayResponse>, Status> {
auth(&self.plugins, &req, "display.clear")?;
match self.kernel.display.clear() {
Ok(()) => Ok(Response::new(DisplayResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(DisplayResponse { ok: false, error: e })),
}
}
async fn notify(&self, req: Request<DisplayNotifyRequest>) -> Result<Response<DisplayResponse>, Status> {
auth(&self.plugins, &req, "display.notify")?;
let r = req.into_inner();
match self.kernel.display.notify(&r.message) {
Ok(()) => Ok(Response::new(DisplayResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(DisplayResponse { ok: false, error: e })),
}
}
}
// ===== Audio =====
pub struct AudioSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl AudioService for AudioSvc {
async fn play(&self, req: Request<tonic::Streaming<AudioChunk>>) -> Result<Response<AudioResponse>, Status> {
auth(&self.plugins, &req, "audio.play")?;
use tokio_stream::StreamExt;
let mut stream = req.into_inner();
let mut all_data = Vec::new();
let mut format = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if format.is_empty() {
format = chunk.format;
}
all_data.extend(chunk.data);
}
match self.kernel.audio.play(&all_data, &format) {
Ok(()) => Ok(Response::new(AudioResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(AudioResponse { ok: false, error: e })),
}
}
async fn stop(&self, req: Request<Empty>) -> Result<Response<AudioResponse>, Status> {
auth(&self.plugins, &req, "audio.stop")?;
match self.kernel.audio.stop() {
Ok(()) => Ok(Response::new(AudioResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(AudioResponse { ok: false, error: e })),
}
}
async fn volume(&self, req: Request<VolumeRequest>) -> Result<Response<AudioResponse>, Status> {
auth(&self.plugins, &req, "audio.volume")?;
let r = req.into_inner();
match self.kernel.audio.volume(r.level) {
Ok(()) => Ok(Response::new(AudioResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(AudioResponse { ok: false, error: e })),
}
}
async fn record_start(&self, req: Request<RecordStartRequest>) -> Result<Response<AudioResponse>, Status> {
auth(&self.plugins, &req, "audio.record_start")?;
let r = req.into_inner();
match self.kernel.audio.record_start(&r.format) {
Ok(()) => Ok(Response::new(AudioResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(AudioResponse { ok: false, error: e })),
}
}
async fn record_stop(&self, req: Request<Empty>) -> Result<Response<AudioData>, Status> {
auth(&self.plugins, &req, "audio.record_stop")?;
match self.kernel.audio.record_stop() {
Ok(data) => Ok(Response::new(AudioData { data, format: String::new() })),
Err(e) => Err(Status::internal(e)),
}
}
type RecordStreamStream = tokio_stream::wrappers::ReceiverStream<Result<AudioChunk, Status>>;
async fn record_stream(&self, req: Request<RecordStartRequest>) -> Result<Response<Self::RecordStreamStream>, Status> {
auth(&self.plugins, &req, "audio.record_stream")?;
let (_tx, rx) = tokio::sync::mpsc::channel(16);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
}
// ===== FS =====
pub struct FsSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl FsService for FsSvc {
async fn read(&self, req: Request<FsReadRequest>) -> Result<Response<FsReadResponse>, Status> {
auth(&self.plugins, &req, "fs.read")?;
let r = req.into_inner();
match self.kernel.fs.read(&r.path, r.offset, r.length).await {
Ok(data) => Ok(Response::new(FsReadResponse { data, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(FsReadResponse { data: vec![], ok: false, error: e })),
}
}
async fn write(&self, req: Request<FsWriteRequest>) -> Result<Response<FsResponse>, Status> {
auth(&self.plugins, &req, "fs.write")?;
let r = req.into_inner();
match self.kernel.fs.write(&r.path, &r.data, r.append).await {
Ok(()) => Ok(Response::new(FsResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(FsResponse { ok: false, error: e })),
}
}
async fn delete(&self, req: Request<FsPathRequest>) -> Result<Response<FsResponse>, Status> {
auth(&self.plugins, &req, "fs.delete")?;
let r = req.into_inner();
match self.kernel.fs.delete(&r.path).await {
Ok(()) => Ok(Response::new(FsResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(FsResponse { ok: false, error: e })),
}
}
async fn rename(&self, req: Request<FsRenameRequest>) -> Result<Response<FsResponse>, Status> {
auth(&self.plugins, &req, "fs.rename")?;
let r = req.into_inner();
match self.kernel.fs.rename(&r.from, &r.to).await {
Ok(()) => Ok(Response::new(FsResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(FsResponse { ok: false, error: e })),
}
}
async fn stat(&self, req: Request<FsPathRequest>) -> Result<Response<FsStatResponse>, Status> {
auth(&self.plugins, &req, "fs.stat")?;
let r = req.into_inner();
match self.kernel.fs.stat(&r.path).await {
Ok(info) => Ok(Response::new(FsStatResponse {
ok: true, error: String::new(),
size: info.size, is_dir: info.is_dir, is_file: info.is_file,
modified: info.modified, created: info.created, permissions: 0,
})),
Err(e) => Ok(Response::new(FsStatResponse {
ok: false, error: e, size: 0, is_dir: false, is_file: false,
modified: 0, created: 0, permissions: 0,
})),
}
}
async fn list(&self, req: Request<FsPathRequest>) -> Result<Response<FsListResponse>, Status> {
auth(&self.plugins, &req, "fs.list")?;
let r = req.into_inner();
match self.kernel.fs.list(&r.path).await {
Ok(entries) => Ok(Response::new(FsListResponse {
ok: true, error: String::new(),
entries: entries.into_iter().map(|e| FsEntry {
name: e.name, is_dir: e.is_dir, size: e.size,
}).collect(),
})),
Err(e) => Ok(Response::new(FsListResponse { ok: false, error: e, entries: vec![] })),
}
}
type WatchStream = tokio_stream::wrappers::ReceiverStream<Result<FsWatchEvent, Status>>;
async fn watch(&self, req: Request<FsPathRequest>) -> Result<Response<Self::WatchStream>, Status> {
auth(&self.plugins, &req, "fs.watch")?;
let (_tx, rx) = tokio::sync::mpsc::channel(16);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
}
// ===== Memory =====
pub struct MemorySvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl MemoryService for MemorySvc {
async fn read(&self, req: Request<MemoryReadRequest>) -> Result<Response<MemoryReadResponse>, Status> {
auth(&self.plugins, &req, "memory.read")?;
let r = req.into_inner();
match self.kernel.memory.read(&r.key) {
Ok(Some(v)) => Ok(Response::new(MemoryReadResponse { value: v, found: true, error: String::new() })),
Ok(None) => Ok(Response::new(MemoryReadResponse { value: vec![], found: false, error: String::new() })),
Err(e) => Ok(Response::new(MemoryReadResponse { value: vec![], found: false, error: e })),
}
}
async fn write(&self, req: Request<MemoryWriteRequest>) -> Result<Response<MemoryResponse>, Status> {
auth(&self.plugins, &req, "memory.write")?;
let r = req.into_inner();
match self.kernel.memory.write(&r.key, &r.value) {
Ok(()) => Ok(Response::new(MemoryResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(MemoryResponse { ok: false, error: e })),
}
}
async fn delete(&self, req: Request<MemoryKeyRequest>) -> Result<Response<MemoryResponse>, Status> {
auth(&self.plugins, &req, "memory.delete")?;
let r = req.into_inner();
match self.kernel.memory.delete(&r.key) {
Ok(()) => Ok(Response::new(MemoryResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(MemoryResponse { ok: false, error: e })),
}
}
async fn list(&self, req: Request<MemoryListRequest>) -> Result<Response<MemoryListResponse>, Status> {
auth(&self.plugins, &req, "memory.list")?;
let r = req.into_inner();
match self.kernel.memory.list(&r.prefix, r.limit) {
Ok(keys) => Ok(Response::new(MemoryListResponse { keys, error: String::new() })),
Err(e) => Ok(Response::new(MemoryListResponse { keys: vec![], error: e })),
}
}
async fn search(&self, req: Request<MemorySearchRequest>) -> Result<Response<MemorySearchResponse>, Status> {
auth(&self.plugins, &req, "memory.search")?;
let r = req.into_inner();
match self.kernel.memory.search(&r.query, r.limit) {
Ok(hits) => Ok(Response::new(MemorySearchResponse {
hits: hits.into_iter().map(|(k, s, sc)| MemorySearchHit { key: k, snippet: s, score: sc }).collect(),
error: String::new(),
})),
Err(e) => Ok(Response::new(MemorySearchResponse { hits: vec![], error: e })),
}
}
}
// ===== Network =====
pub struct NetworkSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl NetworkService for NetworkSvc {
async fn request(&self, req: Request<HttpRequest>) -> Result<Response<HttpResponse>, Status> {
auth(&self.plugins, &req, "network.request")?;
let r = req.into_inner();
match self.kernel.network.request(&r.url, &r.method, &r.headers, &r.body).await {
Ok((status, headers, body)) => Ok(Response::new(HttpResponse { status, headers, body, error: String::new() })),
Err(e) => Ok(Response::new(HttpResponse { status: 0, headers: Default::default(), body: vec![], error: e })),
}
}
type OpenConnStream = tokio_stream::wrappers::ReceiverStream<Result<DataChunk, Status>>;
async fn open_conn(&self, req: Request<ConnectRequest>) -> Result<Response<Self::OpenConnStream>, Status> {
auth(&self.plugins, &req, "network.open_conn")?;
let (_tx, rx) = tokio::sync::mpsc::channel(16);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
type ListenStream = tokio_stream::wrappers::ReceiverStream<Result<ConnEvent, Status>>;
async fn listen(&self, req: Request<ListenRequest>) -> Result<Response<Self::ListenStream>, Status> {
auth(&self.plugins, &req, "network.listen")?;
let (_tx, rx) = tokio::sync::mpsc::channel(16);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(&self, req: Request<SendRequest>) -> Result<Response<NetResponse>, Status> {
auth(&self.plugins, &req, "network.send")?;
Ok(Response::new(NetResponse { ok: false, error: "send: not yet implemented".to_string() }))
}
async fn close(&self, req: Request<CloseRequest>) -> Result<Response<NetResponse>, Status> {
auth(&self.plugins, &req, "network.close")?;
Ok(Response::new(NetResponse { ok: false, error: "close: not yet implemented".to_string() }))
}
async fn available(&self, req: Request<Empty>) -> Result<Response<NetAvailableResponse>, Status> {
auth(&self.plugins, &req, "network.available")?;
Ok(Response::new(NetAvailableResponse { available: self.kernel.network.available() }))
}
}
// ===== Input =====
pub struct InputSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl InputService for InputSvc {
async fn text(&self, req: Request<Empty>) -> Result<Response<InputTextResponse>, Status> {
auth(&self.plugins, &req, "input.text")?;
match self.kernel.input.text().await {
Ok(t) => Ok(Response::new(InputTextResponse { text: t, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(InputTextResponse { text: String::new(), ok: false, error: e })),
}
}
async fn key(&self, req: Request<Empty>) -> Result<Response<InputKeyResponse>, Status> {
auth(&self.plugins, &req, "input.key")?;
Ok(Response::new(InputKeyResponse { key: String::new(), action: String::new(), modifiers: vec![] }))
}
async fn clipboard(&self, req: Request<Empty>) -> Result<Response<InputClipboardResponse>, Status> {
auth(&self.plugins, &req, "input.clipboard")?;
Ok(Response::new(InputClipboardResponse {
data: vec![], mime: String::new(), ok: false, error: "not implemented".to_string(),
}))
}
type EventsStream = tokio_stream::wrappers::ReceiverStream<Result<InputEvent, Status>>;
async fn events(&self, req: Request<Empty>) -> Result<Response<Self::EventsStream>, Status> {
auth(&self.plugins, &req, "input.events")?;
let (_tx, rx) = tokio::sync::mpsc::channel(16);
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
}
// ===== Process =====
pub struct ProcessSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl ProcessService for ProcessSvc {
async fn spawn(&self, req: Request<SpawnRequest>) -> Result<Response<SpawnResponse>, Status> {
auth(&self.plugins, &req, "process.spawn")?;
let r = req.into_inner();
let args: Vec<String> = r.args;
match self.kernel.process.spawn(&r.cmd, &args, &r.env, &r.cwd).await {
Ok(pid) => Ok(Response::new(SpawnResponse { pid, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(SpawnResponse { pid: 0, ok: false, error: e })),
}
}
async fn kill(&self, req: Request<PidRequest>) -> Result<Response<ProcResponse>, Status> {
auth(&self.plugins, &req, "process.kill")?;
let r = req.into_inner();
match self.kernel.process.kill(r.pid).await {
Ok(()) => Ok(Response::new(ProcResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(ProcResponse { ok: false, error: e })),
}
}
async fn wait(&self, req: Request<PidRequest>) -> Result<Response<WaitResponse>, Status> {
auth(&self.plugins, &req, "process.wait")?;
let r = req.into_inner();
match self.kernel.process.wait(r.pid).await {
Ok(code) => Ok(Response::new(WaitResponse { exit_code: code, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(WaitResponse { exit_code: -1, ok: false, error: e })),
}
}
async fn stdin(&self, req: Request<StdinRequest>) -> Result<Response<ProcResponse>, Status> {
auth(&self.plugins, &req, "process.stdin")?;
let r = req.into_inner();
match self.kernel.process.write_stdin(r.pid, &r.data).await {
Ok(()) => Ok(Response::new(ProcResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(ProcResponse { ok: false, error: e })),
}
}
type StdoutStream = tokio_stream::wrappers::ReceiverStream<Result<DataChunk, Status>>;
async fn stdout(&self, req: Request<PidRequest>) -> Result<Response<Self::StdoutStream>, Status> {
auth(&self.plugins, &req, "process.stdout")?;
let r = req.into_inner();
let (tx, rx) = tokio::sync::mpsc::channel(64);
let kernel = self.kernel.clone();
let pid = r.pid;
tokio::spawn(async move {
loop {
let mut buf = vec![0u8; 4096];
let result = kernel.process.read_stdout(pid, &mut buf).await;
match result {
Ok(0) => break,
Ok(n) => {
if tx.send(Ok(DataChunk { data: buf[..n].to_vec() })).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn signal(&self, req: Request<SignalRequest>) -> Result<Response<ProcResponse>, Status> {
auth(&self.plugins, &req, "process.signal")?;
let r = req.into_inner();
match self.kernel.process.signal(r.pid, r.signal).await {
Ok(()) => Ok(Response::new(ProcResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(ProcResponse { ok: false, error: e })),
}
}
async fn list_proc(&self, req: Request<Empty>) -> Result<Response<ProcListResponse>, Status> {
auth(&self.plugins, &req, "process.list_proc")?;
let procs = self.kernel.process.list();
Ok(Response::new(ProcListResponse {
procs: procs.into_iter().map(|(pid, cmd, status)| ProcInfo { pid, cmd, status }).collect(),
}))
}
}
// ===== Timer =====
pub struct TimerSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl TimerService for TimerSvc {
async fn once(&self, req: Request<TimerOnceRequest>) -> Result<Response<TimerResponse>, Status> {
let plugin_id = auth(&self.plugins, &req, "timer.once")?;
let r = req.into_inner();
let id = self.kernel.timer.once(r.delay_ms, r.callback_id, plugin_id);
Ok(Response::new(TimerResponse { timer_id: id, ok: true, error: String::new() }))
}
async fn cron(&self, req: Request<TimerCronRequest>) -> Result<Response<TimerResponse>, Status> {
auth(&self.plugins, &req, "timer.cron")?;
Ok(Response::new(TimerResponse {
timer_id: String::new(), ok: false, error: "cron: not yet implemented".to_string(),
}))
}
async fn cancel(&self, req: Request<TimerCancelRequest>) -> Result<Response<TimerResponse>, Status> {
auth(&self.plugins, &req, "timer.cancel")?;
let r = req.into_inner();
match self.kernel.timer.cancel(&r.timer_id) {
Ok(()) => Ok(Response::new(TimerResponse { timer_id: r.timer_id, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(TimerResponse { timer_id: r.timer_id, ok: false, error: e })),
}
}
async fn now(&self, req: Request<Empty>) -> Result<Response<TimerNowResponse>, Status> {
auth(&self.plugins, &req, "timer.now")?;
Ok(Response::new(TimerNowResponse { timestamp_ms: self.kernel.timer.now() }))
}
}
// ===== HID =====
pub struct HidSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl HidService for HidSvc {
async fn capture(&self, req: Request<CaptureRequest>) -> Result<Response<CaptureResponse>, Status> {
auth(&self.plugins, &req, "hid.capture")?;
let r = req.into_inner();
match self.kernel.hid.capture(r.x, r.y, r.width, r.height) {
Ok(image) => Ok(Response::new(CaptureResponse { image, format: "png".to_string(), ok: true, error: String::new() })),
Err(e) => Ok(Response::new(CaptureResponse { image: vec![], format: String::new(), ok: false, error: e })),
}
}
async fn mouse(&self, req: Request<MouseRequest>) -> Result<Response<HidResponse>, Status> {
auth(&self.plugins, &req, "hid.mouse")?;
let r = req.into_inner();
match self.kernel.hid.mouse(r.x, r.y, &r.action, &r.button) {
Ok(()) => Ok(Response::new(HidResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(HidResponse { ok: false, error: e })),
}
}
async fn keyboard(&self, req: Request<KeyboardRequest>) -> Result<Response<HidResponse>, Status> {
auth(&self.plugins, &req, "hid.keyboard")?;
let r = req.into_inner();
match self.kernel.hid.keyboard(&r.key, &r.action) {
Ok(()) => Ok(Response::new(HidResponse { ok: true, error: String::new() })),
Err(e) => Ok(Response::new(HidResponse { ok: false, error: e })),
}
}
async fn ocr(&self, req: Request<CaptureRequest>) -> Result<Response<OcrResponse>, Status> {
auth(&self.plugins, &req, "hid.ocr")?;
let r = req.into_inner();
match self.kernel.hid.ocr(r.x, r.y, r.width, r.height) {
Ok(text) => Ok(Response::new(OcrResponse { text, ok: true, error: String::new() })),
Err(e) => Ok(Response::new(OcrResponse { text: String::new(), ok: false, error: e })),
}
}
}
// ===== Plugin Bridge =====
pub struct BridgeSvc {
kernel: Arc<Kernel>,
plugins: Arc<PluginManager>,
}
#[tonic::async_trait]
impl PluginBridgeService for BridgeSvc {
async fn register(&self, req: Request<PluginInfo>) -> Result<Response<RegisterResponse>, Status> {
let r = req.into_inner();
let record = PluginRecord {
id: r.id,
name: r.name,
version: r.version,
plugin_type: r.plugin_type,
syscalls: r.syscalls,
depends: r.depends,
exports: r.exports.into_iter().map(|e| (e.name, e.description)).collect(),
status: PluginStatus::Running,
};
match self.plugins.register(record) {
Ok(()) => Ok(Response::new(RegisterResponse { ok: true, error: String::new(), session_id: uuid::Uuid::new_v4().to_string() })),
Err(e) => Ok(Response::new(RegisterResponse { ok: false, error: e, session_id: String::new() })),
}
}
async fn call(&self, req: Request<CallRequest>) -> Result<Response<CallResponse>, Status> {
let r = req.into_inner();
let fn_name = &r.r#fn;
match self.plugins.find_export(&r.target, fn_name) {
Some(_plugin_id) => Ok(Response::new(CallResponse {
result: vec![], ok: false,
error: format!("call routing not yet implemented: {}.{}", r.target, fn_name),
})),
None => Ok(Response::new(CallResponse {
result: vec![], ok: false,
error: format!("export not found: {}.{}", r.target, fn_name),
})),
}
}
type StreamCallStream = tokio_stream::wrappers::ReceiverStream<Result<CallResponse, Status>>;
async fn stream_call(&self, req: Request<CallRequest>) -> Result<Response<Self::StreamCallStream>, Status> {
let metadata_plugin_id = permission::plugin_id_from_metadata(req.metadata());
let r = req.into_inner();
let target = r.target;
if let Some(ref metadata_id) = metadata_plugin_id {
if !target.is_empty() && metadata_id != &target {
return Err(Status::permission_denied(format!(
"plugin '{}' cannot subscribe to '{}' events",
metadata_id, target
)));
}
}
let plugin_id = metadata_plugin_id.unwrap_or(target);
if plugin_id.is_empty() {
return Err(Status::invalid_argument("stream_call requires target or x-plugin-id"));
}
permission::require_running_plugin(&self.plugins, &plugin_id)?;
let mut events = self.kernel.callback_bus.subscribe(&plugin_id);
let (tx, rx) = tokio::sync::mpsc::channel(16);
let bus = self.kernel.callback_bus.clone();
let subscribed_plugin_id = plugin_id.clone();
tokio::spawn(async move {
while let Some(event) = events.recv().await {
let payload = serde_json::json!({
"event": "timer",
"timer_id": event.timer_id,
"callback_id": event.callback_id,
});
let response = CallResponse { result: payload.to_string().into_bytes(), ok: true, error: String::new() };
if tx.send(Ok(response)).await.is_err() {
break;
}
}
bus.remove(&subscribed_plugin_id);
});
Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn heartbeat(&self, _req: Request<HeartbeatRequest>) -> Result<Response<HeartbeatResponse>, Status> {
Ok(Response::new(HeartbeatResponse { ok: true }))
}
}
fn display_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> DisplaySvc {
DisplaySvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn audio_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> AudioSvc {
AudioSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn fs_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> FsSvc {
FsSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn memory_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> MemorySvc {
MemorySvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn network_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> NetworkSvc {
NetworkSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn input_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> InputSvc {
InputSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn process_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> ProcessSvc {
ProcessSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn timer_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> TimerSvc {
TimerSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
fn hid_svc(kernel: &Arc<Kernel>, plugins: &Arc<PluginManager>) -> HidSvc {
HidSvc { kernel: kernel.clone(), plugins: plugins.clone() }
}
// ===== Server startup =====
pub async fn run(kernel: Kernel) -> anyhow::Result<()> {
use agentsd_proto::agentsd::{
audio_server::AudioServer, display_server::DisplayServer, fs_server::FsServer,
hid_server::HidServer, input_server::InputServer, memory_server::MemoryServer,
network_server::NetworkServer, plugin_bridge_server::PluginBridgeServer,
process_server::ProcessServer, timer_server::TimerServer,
};
use tonic::transport::Server;
let server = AgentsdServer::new(kernel);
let addr = "[::1]:50051".parse()?;
tracing::info!("agentsd listening on {}", addr);
Server::builder()
.add_service(DisplayServer::new(display_svc(&server.kernel, &server.plugins)))
.add_service(AudioServer::new(audio_svc(&server.kernel, &server.plugins)))
.add_service(FsServer::new(fs_svc(&server.kernel, &server.plugins)))
.add_service(MemoryServer::new(memory_svc(&server.kernel, &server.plugins)))
.add_service(NetworkServer::new(network_svc(&server.kernel, &server.plugins)))
.add_service(InputServer::new(input_svc(&server.kernel, &server.plugins)))
.add_service(ProcessServer::new(process_svc(&server.kernel, &server.plugins)))
.add_service(TimerServer::new(timer_svc(&server.kernel, &server.plugins)))
.add_service(HidServer::new(hid_svc(&server.kernel, &server.plugins)))
.add_service(PluginBridgeServer::new(BridgeSvc { kernel: server.kernel.clone(), plugins: server.plugins.clone() }))
.serve_with_shutdown(addr, async {
tokio::signal::ctrl_c().await.ok();
tracing::info!("shutdown signal received, stopping...");
})
.await?;
tracing::info!("agentsd stopped");
Ok(())
}

View File

@@ -0,0 +1,54 @@
/// Audio syscall: playback and recording.
/// Actual implementation delegated to system plugins.
/// This is the kernel-side stub that dispatches to the active audio driver.
pub struct AudioSyscall {
available: bool,
}
impl AudioSyscall {
pub fn new() -> Self {
// Audio availability detected at runtime
Self { available: false }
}
#[allow(dead_code)]
pub fn is_available(&self) -> bool {
self.available
}
pub fn play(&self, _data: &[u8], _format: &str) -> Result<(), String> {
if !self.available {
return Err("audio: no driver loaded".to_string());
}
// Dispatched to system plugin via FFI
Ok(())
}
pub fn stop(&self) -> Result<(), String> {
if !self.available {
return Err("audio: no driver loaded".to_string());
}
Ok(())
}
pub fn volume(&self, _level: f32) -> Result<(), String> {
if !self.available {
return Err("audio: no driver loaded".to_string());
}
Ok(())
}
pub fn record_start(&self, _format: &str) -> Result<(), String> {
if !self.available {
return Err("audio: no driver loaded".to_string());
}
Ok(())
}
pub fn record_stop(&self) -> Result<Vec<u8>, String> {
if !self.available {
return Err("audio: no driver loaded".to_string());
}
Ok(Vec::new())
}
}

View File

@@ -0,0 +1,39 @@
use std::io::Write as IoWrite;
/// Display syscall: output to user.
pub struct DisplaySyscall;
impl DisplaySyscall {
pub fn new() -> Self {
Self
}
pub fn text(&self, content: &str) -> Result<(), String> {
let stdout = std::io::stdout();
let mut handle = stdout.lock();
handle
.write_all(content.as_bytes())
.map_err(|e| e.to_string())?;
handle.write_all(b"\n").map_err(|e| e.to_string())?;
handle.flush().map_err(|e| e.to_string())
}
pub fn rich(&self, markup: &str, _format: &str) -> Result<(), String> {
// For now, render as plain text. Rich rendering is a system plugin concern.
self.text(markup)
}
pub fn image(&self, _data: &[u8], _format: &str) -> Result<(), String> {
self.text("[image]")
}
pub fn clear(&self) -> Result<(), String> {
// ANSI clear
print!("\x1b[2J\x1b[H");
std::io::stdout().flush().map_err(|e| e.to_string())
}
pub fn notify(&self, message: &str) -> Result<(), String> {
self.text(&format!("[notify] {}", message))
}
}

View File

@@ -0,0 +1,114 @@
use std::path::Path;
use tokio::fs as async_fs;
/// FS syscall: file system operations.
pub struct FsSyscall;
impl FsSyscall {
pub fn new() -> Self {
Self
}
pub async fn read(&self, path: &str, offset: i64, length: i64) -> Result<Vec<u8>, String> {
let data = async_fs::read(path).await.map_err(|e| e.to_string())?;
let start = if offset > 0 { offset as usize } else { 0 };
let end = if length > 0 {
std::cmp::min(start + length as usize, data.len())
} else {
data.len()
};
if start >= data.len() {
return Ok(Vec::new());
}
Ok(data[start..end].to_vec())
}
pub async fn write(&self, path: &str, data: &[u8], append: bool) -> Result<(), String> {
if let Some(parent) = Path::new(path).parent() {
async_fs::create_dir_all(parent)
.await
.map_err(|e| e.to_string())?;
}
if append {
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
.map_err(|e| e.to_string())?;
file.write_all(data).await.map_err(|e| e.to_string())
} else {
async_fs::write(path, data).await.map_err(|e| e.to_string())
}
}
pub async fn delete(&self, path: &str) -> Result<(), String> {
let meta = async_fs::metadata(path).await.map_err(|e| e.to_string())?;
if meta.is_dir() {
async_fs::remove_dir_all(path)
.await
.map_err(|e| e.to_string())
} else {
async_fs::remove_file(path)
.await
.map_err(|e| e.to_string())
}
}
pub async fn rename(&self, from: &str, to: &str) -> Result<(), String> {
async_fs::rename(from, to).await.map_err(|e| e.to_string())
}
pub async fn stat(&self, path: &str) -> Result<FsStatInfo, String> {
let meta = async_fs::metadata(path).await.map_err(|e| e.to_string())?;
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let created = meta
.created()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
Ok(FsStatInfo {
size: meta.len() as i64,
is_dir: meta.is_dir(),
is_file: meta.is_file(),
modified,
created,
})
}
pub async fn list(&self, dir: &str) -> Result<Vec<FsEntryInfo>, String> {
let mut entries = Vec::new();
let mut read_dir = async_fs::read_dir(dir).await.map_err(|e| e.to_string())?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
let meta = entry.metadata().await.map_err(|e| e.to_string())?;
entries.push(FsEntryInfo {
name: entry.file_name().to_string_lossy().to_string(),
is_dir: meta.is_dir(),
size: meta.len() as i64,
});
}
Ok(entries)
}
}
pub struct FsStatInfo {
pub size: i64,
pub is_dir: bool,
pub is_file: bool,
pub modified: i64,
pub created: i64,
}
pub struct FsEntryInfo {
pub name: String,
pub is_dir: bool,
pub size: i64,
}

View File

@@ -0,0 +1,50 @@
/// HID syscall: screen capture and input simulation.
/// Capability can be absent (headless environments).
pub struct HidSyscall {
available: bool,
}
impl HidSyscall {
pub fn new() -> Self {
// Detect if desktop environment is available
let available = std::env::var("DISPLAY").is_ok()
|| std::env::var("WAYLAND_DISPLAY").is_ok()
|| cfg!(target_os = "windows")
|| cfg!(target_os = "macos");
Self { available }
}
#[allow(dead_code)]
pub fn is_available(&self) -> bool {
self.available
}
pub fn capture(&self, _x: i32, _y: i32, _w: i32, _h: i32) -> Result<Vec<u8>, String> {
if !self.available {
return Err("hid: no desktop environment".to_string());
}
// Stub: actual implementation via system plugin
Err("hid.capture: not implemented (requires system plugin)".to_string())
}
pub fn mouse(&self, _x: i32, _y: i32, _action: &str, _button: &str) -> Result<(), String> {
if !self.available {
return Err("hid: no desktop environment".to_string());
}
Err("hid.mouse: not implemented (requires system plugin)".to_string())
}
pub fn keyboard(&self, _key: &str, _action: &str) -> Result<(), String> {
if !self.available {
return Err("hid: no desktop environment".to_string());
}
Err("hid.keyboard: not implemented (requires system plugin)".to_string())
}
pub fn ocr(&self, _x: i32, _y: i32, _w: i32, _h: i32) -> Result<String, String> {
if !self.available {
return Err("hid: no desktop environment".to_string());
}
Err("hid.ocr: not implemented (requires system plugin)".to_string())
}
}

View File

@@ -0,0 +1,21 @@
use tokio::io::{self, AsyncBufReadExt, BufReader};
/// Input syscall: receive user input.
pub struct InputSyscall;
impl InputSyscall {
pub fn new() -> Self {
Self
}
pub async fn text(&self) -> Result<String, String> {
let stdin = io::stdin();
let mut reader = BufReader::new(stdin);
let mut line = String::new();
reader
.read_line(&mut line)
.await
.map_err(|e| e.to_string())?;
Ok(line.trim_end().to_string())
}
}

View File

@@ -0,0 +1,111 @@
use rusqlite::{params, Connection};
use std::sync::Mutex;
/// Memory syscall: structured KV storage with FTS5 full-text search.
/// Data is persisted to `<exe_dir>/data/memory.db`.
pub struct MemorySyscall {
conn: Mutex<Connection>,
}
impl MemorySyscall {
pub fn new() -> anyhow::Result<Self> {
let db_path = crate::paths::data_dir().join("memory.db");
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value BLOB NOT NULL,
text_value TEXT,
updated_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS kv_fts USING fts5(
key, text_value, tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS kv_ai AFTER INSERT ON kv BEGIN
INSERT INTO kv_fts(key, text_value) VALUES (NEW.key, NEW.text_value);
END;
CREATE TRIGGER IF NOT EXISTS kv_au AFTER UPDATE ON kv BEGIN
DELETE FROM kv_fts WHERE key = OLD.key;
INSERT INTO kv_fts(key, text_value) VALUES (NEW.key, NEW.text_value);
END;
CREATE TRIGGER IF NOT EXISTS kv_ad AFTER DELETE ON kv BEGIN
DELETE FROM kv_fts WHERE key = OLD.key;
END;",
)?;
tracing::info!("memory: opened {}", db_path.display());
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn read(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT value FROM kv WHERE key = ?1")
.map_err(|e| e.to_string())?;
let result = stmt
.query_row(params![key], |row| row.get::<_, Vec<u8>>(0))
.ok();
Ok(result)
}
pub fn write(&self, key: &str, value: &[u8]) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
let text_value = String::from_utf8(value.to_vec()).ok();
conn.execute(
"INSERT OR REPLACE INTO kv (key, value, text_value, updated_at) VALUES (?1, ?2, ?3, strftime('%s','now'))",
params![key, value, text_value],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn delete(&self, key: &str) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM kv WHERE key = ?1", params![key])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn list(&self, prefix: &str, limit: i32) -> Result<Vec<String>, String> {
let conn = self.conn.lock().unwrap();
let lim = if limit > 0 { limit } else { 1000 };
let mut stmt = conn
.prepare("SELECT key FROM kv WHERE key LIKE ?1 ORDER BY key LIMIT ?2")
.map_err(|e| e.to_string())?;
let pattern = format!("{}%", prefix);
let rows = stmt
.query_map(params![pattern, lim], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
let mut keys = Vec::new();
for row in rows {
keys.push(row.map_err(|e| e.to_string())?);
}
Ok(keys)
}
pub fn search(&self, query: &str, limit: i32) -> Result<Vec<(String, String, f32)>, String> {
let conn = self.conn.lock().unwrap();
let lim = if limit > 0 { limit } else { 20 };
let mut stmt = conn
.prepare(
"SELECT key, snippet(kv_fts, 1, '<b>', '</b>', '...', 32), rank
FROM kv_fts WHERE kv_fts MATCH ?1 ORDER BY rank LIMIT ?2",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![query, lim], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, f64>(2)? as f32,
))
})
.map_err(|e| e.to_string())?;
let mut hits = Vec::new();
for row in rows {
hits.push(row.map_err(|e| e.to_string())?);
}
Ok(hits)
}
}

View File

@@ -0,0 +1,46 @@
pub mod display;
pub mod audio;
pub mod fs;
pub mod memory;
pub mod network;
pub mod process;
pub mod timer;
pub mod hid;
pub mod input;
use anyhow::Result;
use std::sync::Arc;
use crate::callback::CallbackBus;
/// The kernel holds all syscall implementations.
pub struct Kernel {
pub display: Arc<display::DisplaySyscall>,
pub audio: Arc<audio::AudioSyscall>,
pub fs: Arc<fs::FsSyscall>,
pub memory: Arc<memory::MemorySyscall>,
pub network: Arc<network::NetworkSyscall>,
pub process: Arc<process::ProcessSyscall>,
pub timer: Arc<timer::TimerSyscall>,
pub hid: Arc<hid::HidSyscall>,
pub input: Arc<input::InputSyscall>,
pub callback_bus: Arc<CallbackBus>,
}
impl Kernel {
pub fn new() -> Result<Self> {
let callback_bus = Arc::new(CallbackBus::new());
Ok(Self {
display: Arc::new(display::DisplaySyscall::new()),
audio: Arc::new(audio::AudioSyscall::new()),
fs: Arc::new(fs::FsSyscall::new()),
memory: Arc::new(memory::MemorySyscall::new()?),
network: Arc::new(network::NetworkSyscall::new()),
process: Arc::new(process::ProcessSyscall::new()),
timer: Arc::new(timer::TimerSyscall::new(callback_bus.clone())),
hid: Arc::new(hid::HidSyscall::new()),
input: Arc::new(input::InputSyscall::new()),
callback_bus,
})
}
}

View File

@@ -0,0 +1,60 @@
/// Network syscall: remote communication.
pub struct NetworkSyscall;
impl NetworkSyscall {
pub fn new() -> Self {
Self
}
pub fn available(&self) -> bool {
// Simple connectivity check: try to resolve a known host
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([8, 8, 8, 8], 53)),
std::time::Duration::from_secs(2),
)
.is_ok()
}
pub async fn request(
&self,
url: &str,
method: &str,
headers: &std::collections::HashMap<String, String>,
body: &[u8],
) -> Result<(i32, std::collections::HashMap<String, String>, Vec<u8>), String> {
// Minimal HTTP client using tokio TcpStream + manual HTTP.
// For production, use hyper or reqwest. For now, shell out to a basic impl.
use tokio::process::Command;
let mut cmd = Command::new("curl");
cmd.arg("-s")
.arg("-X")
.arg(method)
.arg("-o")
.arg("-")
.arg("-w")
.arg("\n%{http_code}");
for (k, v) in headers {
cmd.arg("-H").arg(format!("{}: {}", k, v));
}
if !body.is_empty() {
cmd.arg("-d").arg(String::from_utf8_lossy(body).to_string());
}
cmd.arg(url);
let output = cmd.output().await.map_err(|e| e.to_string())?;
let raw = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = raw.rsplitn(2, '\n').collect();
let status = lines
.first()
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let response_body = lines.get(1).unwrap_or(&"").as_bytes().to_vec();
Ok((status, std::collections::HashMap::new(), response_body))
}
}

View File

@@ -0,0 +1,124 @@
use std::collections::HashMap;
use std::sync::Mutex;
use tokio::process::Command;
struct ManagedProcess {
cmd: String,
}
/// Process syscall: spawn and manage subprocesses.
/// Uses the real OS PID as the identifier.
pub struct ProcessSyscall {
procs: Mutex<HashMap<u64, ManagedProcess>>,
children: tokio::sync::Mutex<HashMap<u64, tokio::process::Child>>,
}
impl ProcessSyscall {
pub fn new() -> Self {
Self {
procs: Mutex::new(HashMap::new()),
children: tokio::sync::Mutex::new(HashMap::new()),
}
}
pub async fn spawn(
&self,
cmd: &str,
args: &[String],
env: &HashMap<String, String>,
cwd: &str,
) -> Result<u64, String> {
let mut command = Command::new(cmd);
command.args(args);
if !cwd.is_empty() {
command.current_dir(cwd);
}
for (k, v) in env {
command.env(k, v);
}
command
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let child = command.spawn().map_err(|e| e.to_string())?;
let pid = child
.id()
.ok_or_else(|| "spawned process has no OS pid".to_string())? as u64;
self.procs.lock().unwrap().insert(
pid,
ManagedProcess {
cmd: cmd.to_string(),
},
);
self.children.lock().await.insert(pid, child);
tracing::info!("process spawned: pid={} cmd={}", pid, cmd);
Ok(pid)
}
pub async fn kill(&self, pid: u64) -> Result<(), String> {
let mut child = {
let mut children = self.children.lock().await;
children.remove(&pid).ok_or_else(|| format!("pid {} not found", pid))?
};
child.kill().await.map_err(|e| e.to_string())?;
self.procs.lock().unwrap().remove(&pid);
tracing::info!("process killed: pid={}", pid);
Ok(())
}
pub async fn wait(&self, pid: u64) -> Result<i32, String> {
let mut child = {
let mut children = self.children.lock().await;
children.remove(&pid).ok_or_else(|| format!("pid {} not found", pid))?
};
self.procs.lock().unwrap().remove(&pid);
let status = child.wait().await.map_err(|e| e.to_string())?;
Ok(status.code().unwrap_or(-1))
}
pub async fn write_stdin(&self, pid: u64, data: &[u8]) -> Result<(), String> {
use tokio::io::AsyncWriteExt;
let mut children = self.children.lock().await;
if let Some(child) = children.get_mut(&pid) {
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(data).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
Ok(())
} else {
Err("stdin not available".to_string())
}
} else {
Err(format!("pid {} not found", pid))
}
}
pub async fn read_stdout(&self, pid: u64, buf: &mut [u8]) -> Result<usize, String> {
use tokio::io::AsyncReadExt;
let mut children = self.children.lock().await;
if let Some(child) = children.get_mut(&pid) {
if let Some(stdout) = child.stdout.as_mut() {
stdout.read(buf).await.map_err(|e| e.to_string())
} else {
Err("stdout not available".to_string())
}
} else {
Err(format!("pid {} not found", pid))
}
}
pub async fn signal(&self, pid: u64, _signal: i32) -> Result<(), String> {
self.kill(pid).await
}
pub fn list(&self) -> Vec<(u64, String, String)> {
let procs = self.procs.lock().unwrap();
procs
.iter()
.map(|(pid, mp)| (*pid, mp.cmd.clone(), "running".to_string()))
.collect()
}
}

View File

@@ -0,0 +1,80 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::time::{self, Duration};
use crate::callback::{CallbackBus, TimerEvent};
struct TimerEntry {
handle: tokio::task::JoinHandle<()>,
}
/// Timer syscall: delayed and periodic execution with callback delivery.
pub struct TimerSyscall {
timers: Arc<Mutex<HashMap<String, TimerEntry>>>,
next_id: Mutex<u64>,
bus: Arc<CallbackBus>,
}
impl TimerSyscall {
pub fn new(bus: Arc<CallbackBus>) -> Self {
Self {
timers: Arc::new(Mutex::new(HashMap::new())),
next_id: Mutex::new(1),
bus,
}
}
/// Schedule a one-shot timer. When it fires, a TimerEvent is sent to the plugin.
pub fn once(&self, delay_ms: u64, callback_id: String, plugin_id: String) -> String {
let timer_id = {
let mut next = self.next_id.lock().unwrap();
let id = format!("timer_{}", *next);
*next += 1;
id
};
let tid = timer_id.clone();
let cb = callback_id.clone();
let pid = plugin_id.clone();
let bus = self.bus.clone();
let timers = self.timers.clone();
let handle = tokio::spawn(async move {
time::sleep(Duration::from_millis(delay_ms)).await;
tracing::info!("timer fired: id={} callback={} plugin={}", tid, cb, pid);
bus.fire(
&pid,
TimerEvent {
timer_id: tid.clone(),
callback_id: cb,
},
);
timers.lock().unwrap().remove(&tid);
});
self.timers.lock().unwrap().insert(
timer_id.clone(),
TimerEntry {
handle,
},
);
timer_id
}
pub fn cancel(&self, timer_id: &str) -> Result<(), String> {
let mut timers = self.timers.lock().unwrap();
if let Some(entry) = timers.remove(timer_id) {
entry.handle.abort();
Ok(())
} else {
Err(format!("timer {} not found", timer_id))
}
}
pub fn now(&self) -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
}

11
core/proto/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "agentsd-proto"
version = "0.1.0"
edition = "2021"
[dependencies]
tonic = { workspace = true }
prost = { workspace = true }
[build-dependencies]
tonic-build = { workspace = true }

7
core/proto/build.rs Normal file
View File

@@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(true)
.build_client(true)
.compile_protos(&["../../proto/agentsd.proto"], &["../../proto/"])?;
Ok(())
}

5
core/proto/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod agentsd {
tonic::include_proto!("agentsd");
}
pub use agentsd::*;

View File

@@ -0,0 +1,164 @@
# 01 - 内核系统调用
agentsd 定义 9 个 syscall 接口。类比 Linux 内核 syscall 表——接口稳定,实现可换(由系统插件提供)。
---
## 1. Display
向用户呈现信息。类比: `write(stdout)` / framebuffer / DRM。
**管**:给人看的输出 | **不管**:文件写入、设备控制
```
display.text(content)
display.rich(markup)
display.image(data, format)
display.clear()
display.notify(message)
```
---
## 2. Audio
声音播放与采集。类比: `/dev/snd` / ALSA / PipeWire。
**管**:扬声器输出、麦克风输入 | **不管**:TTS/STT(插件的事)
```
audio.play(data, format)
audio.stop()
audio.volume(level)
audio.record.start(format)
audio.record.stop() -> data
audio.record.stream(callback)
```
---
## 3. FS
文件系统。类比: `open / read / write / stat / readdir / inotify`
**管**:磁盘文件 | **不管**:结构化数据(Memory)
```
fs.read(path) -> data
fs.write(path, data)
fs.delete(path)
fs.rename(from, to)
fs.stat(path) -> metadata
fs.list(dir) -> entries
fs.watch(path, callback)
```
---
## 4. Memory
结构化存储。类比: `mmap / shmem`,但面向 KV + 索引。
**管**:持久数据、查询 | **不管**:文件(FS)
```
memory.read(key) -> value
memory.write(key, value)
memory.delete(key)
memory.list(prefix?) -> keys
memory.search(query) -> results
```
---
## 5. Network
远程通信。类比: `socket / connect / bind / listen / send / recv`
**管**:对外连接、监听 | **不管**:本地 IPC(内核 gRPC 总线)
```
net.request(url, method, body?) -> response
net.connect(addr, protocol) -> conn
net.listen(port, protocol) -> listener
net.send(conn, data)
net.recv(conn) -> data
net.close(conn)
net.available() -> bool
```
---
## 6. Process
子进程管理。类比: `fork / execve / waitpid / kill / pipe`
**管**:子进程生命周期、stdio | **不管**:插件间路由(内核调度)
```
proc.spawn(cmd, args, env?) -> pid
proc.kill(pid)
proc.wait(pid) -> exitcode
proc.stdin(pid, data)
proc.stdout(pid) -> data
proc.signal(pid, sig)
proc.list() -> [pid]
```
---
## 7. Timer
定时调度。类比: `timer_create / timerfd_create`
**管**:时间触发 | **不管**:用户事件(Input)
```
timer.once(delay, callback) -> id # 到期后通过 PluginBridge.StreamCall 投递事件
timer.cron(expr, callback) -> id
timer.cancel(id)
timer.now() -> timestamp
```
---
## 8. HID
屏幕截取与输入模拟。类比: `/dev/input` + `/dev/fb` + uinput。
能力可缺失。
**管**:屏幕读取、输入模拟 | **不管**:向用户展示(Display)、用户主动输入(Input)
```
hid.capture(region?) -> image
hid.mouse(x, y, action)
hid.keyboard(key, action)
hid.ocr(region?) -> text
```
---
## 9. Input
用户主动输入。类比: `read(stdin)` / evdev。
**管**:用户给的东西 | **不管**:文件读(FS)、录音(Audio)、屏幕读(HID)
```
input.text() -> string
input.key() -> keyevent
input.clipboard() -> data
input.event() -> event
```
---
## 设计原则
1. **接口稳定,实现可换** — syscall 签名不变,系统插件可替换(= 换驱动不改应用)
2. **每个独立** — 可单独实现、测试、编译(= 内核模块独立)
3. **能力可缺失** — 无声卡则 Audio 不可用,不崩(= Linux 无设备则驱动不加载)
4. **权限在此层** — 白名单外的 syscall 拒绝(= seccomp)
5. **离线 = Network 不可用** — 其余八个正常(= 拔网线不影响本地)
6. **内核不做业务** — TTS/浏览器/Agent 全是插件(= 内核不含 Web 服务器)

179
docs/02-插件模型.md Normal file
View File

@@ -0,0 +1,179 @@
# 02 - 插件模型
## 三种插件(对标 Linux 三层)
```
┌─────────────────────────────────────────────────────┐
│ 标准插件 ← 应用服务(nginx、docker) │
│ 可依赖基础插件 + 其他标准插件 │
├─────────────────────────────────────────────────────┤
│ 基础插件 ← 基础 daemon(udevd、resolved) │
│ 只依赖 9 个 syscall,无插件依赖 │
├─────────────────────────────────────────────────────┤
│ 系统插件 ← 内核模块 / 设备驱动(.ko) │
│ 注册为 syscall 的实现 │
├─────────────────────────────────────────────────────┤
│ agentsd ← Linux 内核 + systemd(PID 1) │
└─────────────────────────────────────────────────────┘
```
---
### 系统插件(= 内核模块 / 驱动)
为 syscall 提供具体实现。类比 `insmod alsa_driver.ko`
```yaml
id: alsa-audio
type: system
provides: audio
entry: ./libalsa_audio.so
```
- FFI 加载(.so/.dylib/.dll),零开销
- 只限 Rust / C ABI
- 同一 syscall 可有多个驱动,同时只激活一个
- 无依赖
---
### 基础插件(= systemd 基础 unit,无依赖)
只调 syscall,不调其他插件。类比 `systemd-resolved.service`(只用内核网络栈)。
```yaml
id: tts-edge
type: basic
syscalls:
- audio.play
- network.request
exports:
- name: speak
```
- 独立进程,gRPC/TCP 通信(后续切 UDS/Named Pipe)
- 零插件依赖,加载顺序无所谓(= 无 After/Requires)
- 任意语言
---
### 标准插件(= systemd 应用 unit,有依赖)
组合其他插件。类比 `nginx.service`(After=network.target, Requires=...)。
```yaml
id: voice-agent
type: standard
syscalls:
- input.text
- display.text
depends:
- tts-edge
- stt-whisper
- chat-agent
exports:
- name: voice_chat
```
- 可依赖基础插件 + 其他标准插件
- 按依赖拓扑排序加载(= systemd ordering)
- 任意语言
---
## 通信:gRPC(= D-Bus)
类比 Linux 的 D-Bus 进程间通信总线,agentsd 用 gRPC。
### 为什么 gRPC
- protobuf 二进制,快
- 流式 RPC(音频流、文件 watch)
- 所有语言都有库
- `.proto` = 接口契约 + 代码生成
### 传输分层
| 插件类型 | 传输 | 延迟 | Linux 类比 |
|---|---|---|---|
| 系统插件 | FFI | ~纳秒 | 内核态函数调用 |
| 基础/标准(本机,当前) | gRPC/TCP | ~毫秒 | D-Bus over TCP |
| 基础/标准(目标) | gRPC/Unix socket / Windows Named Pipe | ~微秒 | D-Bus over UDS |
| 远程(可选) | gRPC/TCP | ~毫秒 | D-Bus over TCP |
### proto 示例
```protobuf
syntax = "proto3";
service FS {
rpc Read(ReadRequest) returns (ReadResponse);
rpc Write(WriteRequest) returns (WriteResponse);
rpc Delete(DeleteRequest) returns (DeleteResponse);
rpc List(ListRequest) returns (ListResponse);
rpc Watch(WatchRequest) returns (stream WatchEvent);
}
service Audio {
rpc Play(stream AudioChunk) returns (PlayResponse);
rpc Record(RecordRequest) returns (stream AudioChunk);
}
```
### 插件间调用(经内核路由)
```protobuf
service PluginBridge {
rpc Call(CallRequest) returns (CallResponse);
rpc StreamCall(CallRequest) returns (stream CallResponse);
}
message CallRequest {
string target = 1;
string fn = 2;
bytes args = 3;
}
```
---
## 生命周期管理(= systemctl)
| agentsd 命令 | systemd 对标 | 作用 |
|---|---|---|
| `agentsd start <id>` | `systemctl start` | 启动插件 |
| `agentsd stop <id>` | `systemctl stop` | 停止插件 |
| `agentsd restart <id>` | `systemctl restart` | 重启 |
| `agentsd reload <id>` | `systemctl reload` | 热更新 |
| `agentsd status` | `systemctl status` | 状态查询 |
| `agentsd list` | `systemctl list-units` | 列出所有插件 |
| `agentsd logs <id>` | `journalctl -u` | 查日志 |
| `agentsd enable <id>` | `systemctl enable` | 开机自启 |
| `agentsd disable <id>` | `systemctl disable` | 取消自启 |
---
## 加载顺序(= systemd boot)
```
1. 系统插件(FFI,注册 syscall 实现) ← 类比内核模块加载
2. 基础插件(gRPC,任意顺序) ← 类比 basic.target
3. 标准插件(gRPC,拓扑排序) ← 类比 multi-user.target
```
---
## 看门狗(= Restart=always)
插件崩溃 → agentsd 自动重启(可配置策略):
- `always`:总是重启
- `on-failure`:异常退出才重启
- `never`:不重启
---
## 权限(= seccomp + cgroup)
- 系统插件:内核信任,无限制
- 基础插件:按 `syscalls` 白名单精确到方法(如 `fs.list`)校验,也支持 `fs` / `fs.*` / `*` 授权(= seccomp filter)
- 标准插件:syscalls + depends 白名单,未声明的一律拒绝

50
docs/03-实施路线.md Normal file
View File

@@ -0,0 +1,50 @@
# 03 - 实施路线
## 阶段 1:内核骨架(= 跑起来的 PID 1) ✅ 已完成
- [x] Rust workspace:`agentsd` 主二进制
- [x] gRPC server(tonic over TCP,后续切 UDS)
- [x] 实现 FS syscall(read/write/delete/rename/stat/list)
- [x] 实现 Memory syscall(SQLite KV + FTS5 trigram search,默认写入 data/memory.db)
- [x] 实现 Input syscall(stdin)
- [x] 实现 Display syscall(stdout)
- [x] 实现 Process syscall(spawn/kill/wait/stdin/stdout)
- [x] 实现 Timer syscall(once/cancel/now + 回调事件通道)
- [x] 实现 Audio syscall(stub,待系统插件)
- [x] 实现 HID syscall(stub,待系统插件)
- [x] 实现 Network syscall(HTTP via curl,待替换)
- [x] Plugin Bridge(注册/调用路由/心跳,注册状态写入 data/plugins.json)
- [x] 插件加载:gRPC 握手 + 注册
- [x] echo 测试插件(Python)验证基础链路
- [x] ai_test 手动验证插件(Python,默认 disabled/autoload false)覆盖 Display/Memory/FS/Process/Timer/权限拒绝
- [x] proto 定义:完整 9 个 service + PluginBridge
**验证结果**:agentsd 启动 → Python 插件通过 gRPC 注册 → ai_test 调用 Display/Memory/FS/Process/Timer syscall 并验证权限拒绝。
## 阶段 2:完善核心 syscall
- [ ] Network:替换 curl 为 hyper/reqwest 原生 HTTP client
- [ ] FS.Watch:接入 notify-rs 文件监听
- [ ] Timer.Cron:接入 cron 表达式解析
- [ ] Audio:接入系统音频后端(wasapi/alsa)
- [ ] HID:接入屏幕截取(Windows: win32 API)
- [ ] Unix socket / Named pipe 传输(替代 TCP)
## 阶段 3:接入 Agent
- [ ] Agent 插件:Input → LLM(Network) → Display
- [ ] Hermes adapter(Python gRPC 插件)
- [ ] 本地模型插件(llama.cpp via Process syscall)
## 阶段 4:插件管理(= systemctl)
- [ ] agentsd start / stop / restart / status / list / logs
- [ ] 看门狗(崩溃自动重启)
- [ ] 依赖解析 + 拓扑排序
- [ ] install / uninstall / enable / disable
## 阶段 5:生态
- [ ] Web UI 插件(shell)
- [ ] 语音交互(Audio + TTS/STT 插件)
- [ ] 桌面自动化(HID + computer-use 插件)

117
docs/04-项目结构.md Normal file
View File

@@ -0,0 +1,117 @@
# 04 - 项目结构
```
Agentsd/
├── Cargo.toml # workspace 根
├── README.md # 设计总览
├── docs/ # 设计文档
│ ├── 01-内核系统调用.md
│ ├── 02-插件模型.md
│ ├── 03-实施路线.md
│ └── 04-项目结构.md # 本文件
├── proto/ # 协议定义(单一事实来源)
│ └── agentsd.proto # 9 service + PluginBridge
├── core/ # Rust 源码
│ ├── proto/ # gRPC 协议 crate
│ │ ├── Cargo.toml
│ │ ├── build.rs
│ │ └── src/lib.rs
│ │
│ └── agentsd/ # 主二进制 crate
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # 入口
│ ├── server.rs # gRPC server
│ ├── plugin.rs # 插件注册表 + data/plugins.json 持久化
│ ├── paths.rs # 程序目录 data/ 路径
│ ├── callback.rs # Timer 回调事件通道
│ ├── permission.rs # syscall 权限白名单校验
│ └── syscall/ # 9 个 syscall 模块
│ ├── mod.rs
│ ├── display.rs
│ ├── audio.rs
│ ├── fs.rs
│ ├── memory.rs
│ ├── network.rs
│ ├── process.rs
│ ├── timer.rs
│ ├── hid.rs
│ └── input.rs
├── data/ # 运行时数据(程序启动后自动创建)
│ ├── memory.db # Memory syscall SQLite 数据库
│ └── plugins.json # 插件注册状态
└── plugins/ # 插件(任意语言)
├── _sdk/ # 共享 Python gRPC stub
│ ├── __init__.py
│ ├── agentsd_pb2.py
│ └── agentsd_pb2_grpc.py
├── echo/ # 测试插件
│ ├── plugin.yaml
│ └── echo_plugin.py
├── ai_test/ # AI 手动验证插件(enabled=false,autoload=false)
│ ├── plugin.yaml
│ └── ai_test_plugin.py
├── hermes/ # Hermes Agent 桥接(63 tool)
│ ├── plugin.yaml
│ └── hermes_plugin.py
└── claudecode/ # Claude Code CLI 桥接
├── plugin.yaml
├── claudecode_plugin.py
└── test_chat.py
```
## 布局原则
| 目录 | 职责 | 规则 |
|---|---|---|
| `docs/` | 设计文档 | 只放 .md |
| `proto/` | 协议定义 | 只放 .proto,单一事实来源 |
| `core/` | Rust 代码 | workspace members |
| `data/` | 运行时数据 | 默认路径为程序所在目录的 data 文件夹 |
| `plugins/` | 所有插件 | 每个插件一个目录,含 plugin.yaml |
| `plugins/_sdk/` | 共享 stub | 生成一次,插件 import 引用,不复制 |
## 构建
```bash
cargo build --release # 约 6.1MB 单二进制
```
## 运行
```bash
# 启动内核
./target/release/agentsd.exe
# 启动插件(各开一个终端)
python plugins/hermes/hermes_plugin.py
python plugins/claudecode/claudecode_plugin.py
```
## 手动验证插件
`plugins/ai_test` 是专门给 AI/人工做端到端验证的插件,`plugin.yaml``enabled: false``autoload: false`,默认不应被自动加载。
```bash
RUST_LOG=agentsd=info ./target/release/agentsd.exe
python plugins/ai_test/ai_test_plugin.py
```
## 重新生成 SDK
```bash
python -m grpc_tools.protoc \
-Iproto \
--python_out=plugins/_sdk \
--grpc_python_out=plugins/_sdk \
proto/agentsd.proto
```

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

217
proto/agentsd.proto Normal file
View File

@@ -0,0 +1,217 @@
syntax = "proto3";
package agentsd;
// ========== Display ==========
service Display {
rpc Text(DisplayTextRequest) returns (DisplayResponse);
rpc Rich(DisplayRichRequest) returns (DisplayResponse);
rpc Image(DisplayImageRequest) returns (DisplayResponse);
rpc Clear(Empty) returns (DisplayResponse);
rpc Notify(DisplayNotifyRequest) returns (DisplayResponse);
}
message DisplayTextRequest { string content = 1; }
message DisplayRichRequest { string markup = 1; string format = 2; }
message DisplayImageRequest { bytes data = 1; string format = 2; }
message DisplayNotifyRequest { string message = 1; }
message DisplayResponse { bool ok = 1; string error = 2; }
// ========== Audio ==========
service Audio {
rpc Play(stream AudioChunk) returns (AudioResponse);
rpc Stop(Empty) returns (AudioResponse);
rpc Volume(VolumeRequest) returns (AudioResponse);
rpc RecordStart(RecordStartRequest) returns (AudioResponse);
rpc RecordStop(Empty) returns (AudioData);
rpc RecordStream(RecordStartRequest) returns (stream AudioChunk);
}
message AudioChunk { bytes data = 1; string format = 2; }
message VolumeRequest { float level = 1; }
message RecordStartRequest { string format = 1; }
message AudioData { bytes data = 1; string format = 2; }
message AudioResponse { bool ok = 1; string error = 2; }
// ========== FS ==========
service FS {
rpc Read(FsReadRequest) returns (FsReadResponse);
rpc Write(FsWriteRequest) returns (FsResponse);
rpc Delete(FsPathRequest) returns (FsResponse);
rpc Rename(FsRenameRequest) returns (FsResponse);
rpc Stat(FsPathRequest) returns (FsStatResponse);
rpc List(FsPathRequest) returns (FsListResponse);
rpc Watch(FsPathRequest) returns (stream FsWatchEvent);
}
message FsPathRequest { string path = 1; }
message FsReadRequest { string path = 1; int64 offset = 2; int64 length = 3; }
message FsReadResponse { bytes data = 1; bool ok = 2; string error = 3; }
message FsWriteRequest { string path = 1; bytes data = 2; bool append = 3; }
message FsRenameRequest { string from = 1; string to = 2; }
message FsStatResponse {
bool ok = 1;
string error = 2;
int64 size = 3;
bool is_dir = 4;
bool is_file = 5;
int64 modified = 6;
int64 created = 7;
uint32 permissions = 8;
}
message FsListResponse {
bool ok = 1;
string error = 2;
repeated FsEntry entries = 3;
}
message FsEntry { string name = 1; bool is_dir = 2; int64 size = 3; }
message FsWatchEvent { string path = 1; string kind = 2; }
message FsResponse { bool ok = 1; string error = 2; }
// ========== Memory ==========
service Memory {
rpc Read(MemoryReadRequest) returns (MemoryReadResponse);
rpc Write(MemoryWriteRequest) returns (MemoryResponse);
rpc Delete(MemoryKeyRequest) returns (MemoryResponse);
rpc List(MemoryListRequest) returns (MemoryListResponse);
rpc Search(MemorySearchRequest) returns (MemorySearchResponse);
}
message MemoryKeyRequest { string key = 1; }
message MemoryReadRequest { string key = 1; }
message MemoryReadResponse { bytes value = 1; bool found = 2; string error = 3; }
message MemoryWriteRequest { string key = 1; bytes value = 2; }
message MemoryListRequest { string prefix = 1; int32 limit = 2; }
message MemoryListResponse { repeated string keys = 1; string error = 2; }
message MemorySearchRequest { string query = 1; int32 limit = 2; }
message MemorySearchResponse {
repeated MemorySearchHit hits = 1;
string error = 2;
}
message MemorySearchHit { string key = 1; string snippet = 2; float score = 3; }
message MemoryResponse { bool ok = 1; string error = 2; }
// ========== Network ==========
service Network {
rpc Request(HttpRequest) returns (HttpResponse);
rpc OpenConn(ConnectRequest) returns (stream DataChunk);
rpc Listen(ListenRequest) returns (stream ConnEvent);
rpc Send(SendRequest) returns (NetResponse);
rpc Close(CloseRequest) returns (NetResponse);
rpc Available(Empty) returns (NetAvailableResponse);
}
message HttpRequest {
string url = 1;
string method = 2;
map<string, string> headers = 3;
bytes body = 4;
}
message HttpResponse {
int32 status = 1;
map<string, string> headers = 2;
bytes body = 3;
string error = 4;
}
message ConnectRequest { string addr = 1; string protocol = 2; }
message ListenRequest { uint32 port = 1; string protocol = 2; }
message ConnEvent { string conn_id = 1; bytes data = 2; string kind = 3; }
message SendRequest { string conn_id = 1; bytes data = 2; }
message CloseRequest { string conn_id = 1; }
message DataChunk { bytes data = 1; }
message NetResponse { bool ok = 1; string error = 2; }
message NetAvailableResponse { bool available = 1; }
// ========== Process ==========
service Process {
rpc Spawn(SpawnRequest) returns (SpawnResponse);
rpc Kill(PidRequest) returns (ProcResponse);
rpc Wait(PidRequest) returns (WaitResponse);
rpc Stdin(StdinRequest) returns (ProcResponse);
rpc Stdout(PidRequest) returns (stream DataChunk);
rpc Signal(SignalRequest) returns (ProcResponse);
rpc ListProc(Empty) returns (ProcListResponse);
}
message SpawnRequest {
string cmd = 1;
repeated string args = 2;
map<string, string> env = 3;
string cwd = 4;
}
message SpawnResponse { uint64 pid = 1; bool ok = 2; string error = 3; }
message PidRequest { uint64 pid = 1; }
message WaitResponse { int32 exit_code = 1; bool ok = 2; string error = 3; }
message StdinRequest { uint64 pid = 1; bytes data = 2; }
message SignalRequest { uint64 pid = 1; int32 signal = 2; }
message ProcResponse { bool ok = 1; string error = 2; }
message ProcListResponse { repeated ProcInfo procs = 1; }
message ProcInfo { uint64 pid = 1; string cmd = 2; string status = 3; }
// ========== Timer ==========
service Timer {
rpc Once(TimerOnceRequest) returns (TimerResponse);
rpc Cron(TimerCronRequest) returns (TimerResponse);
rpc Cancel(TimerCancelRequest) returns (TimerResponse);
rpc Now(Empty) returns (TimerNowResponse);
}
message TimerOnceRequest { uint64 delay_ms = 1; string callback_id = 2; }
message TimerCronRequest { string expr = 1; string callback_id = 2; }
message TimerCancelRequest { string timer_id = 1; }
message TimerResponse { string timer_id = 1; bool ok = 2; string error = 3; }
message TimerNowResponse { int64 timestamp_ms = 1; }
// ========== HID ==========
service HID {
rpc Capture(CaptureRequest) returns (CaptureResponse);
rpc Mouse(MouseRequest) returns (HidResponse);
rpc Keyboard(KeyboardRequest) returns (HidResponse);
rpc Ocr(CaptureRequest) returns (OcrResponse);
}
message CaptureRequest { int32 x = 1; int32 y = 2; int32 width = 3; int32 height = 4; }
message CaptureResponse { bytes image = 1; string format = 2; bool ok = 3; string error = 4; }
message MouseRequest { int32 x = 1; int32 y = 2; string action = 3; string button = 4; }
message KeyboardRequest { string key = 1; string action = 2; repeated string modifiers = 3; }
message HidResponse { bool ok = 1; string error = 2; }
message OcrResponse { string text = 1; bool ok = 2; string error = 3; }
// ========== Input ==========
service Input {
rpc Text(Empty) returns (InputTextResponse);
rpc Key(Empty) returns (InputKeyResponse);
rpc Clipboard(Empty) returns (InputClipboardResponse);
rpc Events(Empty) returns (stream InputEvent);
}
message InputTextResponse { string text = 1; bool ok = 2; string error = 3; }
message InputKeyResponse { string key = 1; string action = 2; repeated string modifiers = 3; }
message InputClipboardResponse { bytes data = 1; string mime = 2; bool ok = 3; string error = 4; }
message InputEvent { string type = 1; bytes data = 2; }
// ========== Plugin Bridge ==========
service PluginBridge {
rpc Register(PluginInfo) returns (RegisterResponse);
rpc Call(CallRequest) returns (CallResponse);
rpc StreamCall(CallRequest) returns (stream CallResponse);
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
}
message PluginInfo {
string id = 1;
string name = 2;
string version = 3;
string plugin_type = 4;
repeated string syscalls = 5;
repeated string depends = 6;
repeated ExportDef exports = 7;
}
message ExportDef { string name = 1; string description = 2; }
message RegisterResponse { bool ok = 1; string error = 2; string session_id = 3; }
message CallRequest { string target = 1; string fn = 2; bytes args = 3; }
message CallResponse { bytes result = 1; bool ok = 2; string error = 3; }
message HeartbeatRequest { string plugin_id = 1; }
message HeartbeatResponse { bool ok = 1; }
// ========== Common ==========
message Empty {}