Initial Agentsd project commit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
1763
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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
84
README.md
Normal 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
23
core/agentsd/Cargo.toml
Normal 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 }
|
||||
47
core/agentsd/src/callback.rs
Normal file
47
core/agentsd/src/callback.rs
Normal 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
22
core/agentsd/src/main.rs
Normal 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
12
core/agentsd/src/paths.rs
Normal 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
|
||||
}
|
||||
82
core/agentsd/src/permission.rs
Normal file
82
core/agentsd/src/permission.rs
Normal 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
139
core/agentsd/src/plugin.rs
Normal 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
706
core/agentsd/src/server.rs
Normal 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(())
|
||||
}
|
||||
54
core/agentsd/src/syscall/audio.rs
Normal file
54
core/agentsd/src/syscall/audio.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
39
core/agentsd/src/syscall/display.rs
Normal file
39
core/agentsd/src/syscall/display.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
114
core/agentsd/src/syscall/fs.rs
Normal file
114
core/agentsd/src/syscall/fs.rs
Normal 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,
|
||||
}
|
||||
50
core/agentsd/src/syscall/hid.rs
Normal file
50
core/agentsd/src/syscall/hid.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
21
core/agentsd/src/syscall/input.rs
Normal file
21
core/agentsd/src/syscall/input.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
111
core/agentsd/src/syscall/memory.rs
Normal file
111
core/agentsd/src/syscall/memory.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
46
core/agentsd/src/syscall/mod.rs
Normal file
46
core/agentsd/src/syscall/mod.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
60
core/agentsd/src/syscall/network.rs
Normal file
60
core/agentsd/src/syscall/network.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
124
core/agentsd/src/syscall/process.rs
Normal file
124
core/agentsd/src/syscall/process.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
80
core/agentsd/src/syscall/timer.rs
Normal file
80
core/agentsd/src/syscall/timer.rs
Normal 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
11
core/proto/Cargo.toml
Normal 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
7
core/proto/build.rs
Normal 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
5
core/proto/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod agentsd {
|
||||
tonic::include_proto!("agentsd");
|
||||
}
|
||||
|
||||
pub use agentsd::*;
|
||||
164
docs/01-内核系统调用.md
Normal file
164
docs/01-内核系统调用.md
Normal 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
179
docs/02-插件模型.md
Normal 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
50
docs/03-实施路线.md
Normal 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
117
docs/04-项目结构.md
Normal 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
1
plugins/_sdk/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Agentsd Python SDK - shared gRPC stubs for plugins
|
||||
210
plugins/_sdk/agentsd_pb2.py
Normal file
210
plugins/_sdk/agentsd_pb2.py
Normal file
File diff suppressed because one or more lines are too long
2581
plugins/_sdk/agentsd_pb2_grpc.py
Normal file
2581
plugins/_sdk/agentsd_pb2_grpc.py
Normal file
File diff suppressed because it is too large
Load Diff
222
plugins/ai_test/ai_test_plugin.py
Normal file
222
plugins/ai_test/ai_test_plugin.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
AI Test Plugin for Agentsd
|
||||
==========================
|
||||
|
||||
Manual verification plugin for AI agents and humans. It is intentionally
|
||||
not autoloaded by default; run it only when testing Agentsd runtime behavior.
|
||||
|
||||
Usage:
|
||||
# Start agentsd first:
|
||||
# RUST_LOG=agentsd=info ./target/release/agentsd.exe
|
||||
python plugins/ai_test/ai_test_plugin.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SDK_DIR = ROOT / "plugins" / "_sdk"
|
||||
sys.path.insert(0, str(SDK_DIR))
|
||||
|
||||
import agentsd_pb2 as pb # noqa: E402
|
||||
import agentsd_pb2_grpc as rpc # noqa: E402
|
||||
|
||||
PLUGIN_ID = "ai-test"
|
||||
METADATA = (("x-plugin-id", PLUGIN_ID),)
|
||||
ADDR = "localhost:50051"
|
||||
|
||||
|
||||
class CheckRunner:
|
||||
def __init__(self):
|
||||
self.channel = grpc.insecure_channel(ADDR)
|
||||
self.bridge = rpc.PluginBridgeStub(self.channel)
|
||||
self.display = rpc.DisplayStub(self.channel)
|
||||
self.memory = rpc.MemoryStub(self.channel)
|
||||
self.fs = rpc.FSStub(self.channel)
|
||||
self.process = rpc.ProcessStub(self.channel)
|
||||
self.timer = rpc.TimerStub(self.channel)
|
||||
self.network = rpc.NetworkStub(self.channel)
|
||||
self.results = []
|
||||
self.run_id = str(int(time.time() * 1000))
|
||||
self.scratch_dir = ROOT / "target" / "release" / "data" / "ai_test" / self.run_id
|
||||
|
||||
def close(self):
|
||||
self.channel.close()
|
||||
|
||||
def record(self, name, ok, details=""):
|
||||
icon = "PASS" if ok else "FAIL"
|
||||
print(f"[{icon}] {name}: {details}")
|
||||
self.results.append({"name": name, "ok": bool(ok), "details": details})
|
||||
|
||||
def check(self, name, fn):
|
||||
try:
|
||||
details = fn()
|
||||
self.record(name, True, details or "ok")
|
||||
except Exception as exc:
|
||||
self.record(name, False, str(exc))
|
||||
|
||||
def register(self):
|
||||
resp = self.bridge.Register(pb.PluginInfo(
|
||||
id=PLUGIN_ID,
|
||||
name="AI Test Plugin",
|
||||
version="0.1.0",
|
||||
plugin_type="basic",
|
||||
syscalls=[
|
||||
"display.text",
|
||||
"memory.write", "memory.read", "memory.list", "memory.delete",
|
||||
"fs.write", "fs.read", "fs.stat", "fs.list", "fs.rename", "fs.delete",
|
||||
"process.spawn", "process.stdout", "process.wait",
|
||||
"timer.once",
|
||||
],
|
||||
depends=[],
|
||||
exports=[pb.ExportDef(name="run_checks", description="Run Agentsd verification checks")],
|
||||
))
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
return f"session={resp.session_id}"
|
||||
|
||||
def display_text(self):
|
||||
resp = self.display.Text(
|
||||
pb.DisplayTextRequest(content=f"[ai-test] display check run={self.run_id}"),
|
||||
metadata=METADATA,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
return "display.text ok"
|
||||
|
||||
def memory_roundtrip(self):
|
||||
key = f"ai_test.{self.run_id}.greeting"
|
||||
value = f"hello-{self.run_id}".encode("utf-8")
|
||||
resp = self.memory.Write(pb.MemoryWriteRequest(key=key, value=value), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"write: {resp.error}")
|
||||
resp = self.memory.Read(pb.MemoryReadRequest(key=key), metadata=METADATA)
|
||||
if not resp.found or resp.value != value:
|
||||
raise RuntimeError(f"read mismatch found={resp.found} value={resp.value!r}")
|
||||
resp = self.memory.List(pb.MemoryListRequest(prefix=f"ai_test.{self.run_id}", limit=10), metadata=METADATA)
|
||||
if key not in resp.keys:
|
||||
raise RuntimeError(f"list missing key; keys={list(resp.keys)} error={resp.error!r}")
|
||||
resp = self.memory.Delete(pb.MemoryKeyRequest(key=key), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"delete: {resp.error}")
|
||||
return f"key={key}"
|
||||
|
||||
def fs_roundtrip(self):
|
||||
base = self.scratch_dir
|
||||
file_a = base / "sample.txt"
|
||||
file_b = base / "renamed.txt"
|
||||
data = f"AI_TEST_FS_OK {self.run_id}".encode("utf-8")
|
||||
|
||||
resp = self.fs.Write(pb.FsWriteRequest(path=str(file_a), data=data, append=False), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"write: {resp.error}")
|
||||
|
||||
resp = self.fs.Read(pb.FsReadRequest(path=str(file_a), offset=0, length=0), metadata=METADATA)
|
||||
if not resp.ok or resp.data != data:
|
||||
raise RuntimeError(f"read mismatch ok={resp.ok} data={resp.data!r} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.Stat(pb.FsPathRequest(path=str(file_a)), metadata=METADATA)
|
||||
if not resp.ok or not resp.is_file or resp.size != len(data):
|
||||
raise RuntimeError(f"stat mismatch ok={resp.ok} size={resp.size} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.List(pb.FsPathRequest(path=str(base)), metadata=METADATA)
|
||||
names = [e.name for e in resp.entries]
|
||||
if not resp.ok or "sample.txt" not in names:
|
||||
raise RuntimeError(f"list mismatch ok={resp.ok} names={names} error={resp.error!r}")
|
||||
|
||||
resp = self.fs.Rename(pb.FsRenameRequest(**{"from": str(file_a), "to": str(file_b)}), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"rename: {resp.error}")
|
||||
|
||||
resp = self.fs.Delete(pb.FsPathRequest(path=str(base)), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"delete: {resp.error}")
|
||||
return str(base)
|
||||
|
||||
def process_roundtrip(self):
|
||||
code = "print('AI_TEST_PROCESS_OK')"
|
||||
resp = self.process.Spawn(pb.SpawnRequest(
|
||||
cmd=sys.executable,
|
||||
args=["-c", code],
|
||||
env={},
|
||||
cwd=str(ROOT),
|
||||
), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
pid = resp.pid
|
||||
|
||||
chunks = []
|
||||
for chunk in self.process.Stdout(pb.PidRequest(pid=pid), metadata=METADATA):
|
||||
chunks.append(chunk.data)
|
||||
output = b"".join(chunks).decode("utf-8", errors="replace").strip()
|
||||
|
||||
wait = self.process.Wait(pb.PidRequest(pid=pid), metadata=METADATA)
|
||||
if not wait.ok or wait.exit_code != 0:
|
||||
raise RuntimeError(f"wait failed ok={wait.ok} code={wait.exit_code} error={wait.error!r}")
|
||||
if output != "AI_TEST_PROCESS_OK":
|
||||
raise RuntimeError(f"stdout mismatch: {output!r}")
|
||||
return f"pid={pid} stdout={output}"
|
||||
|
||||
def timer_event(self):
|
||||
stream = self.bridge.StreamCall(
|
||||
pb.CallRequest(target=PLUGIN_ID, fn="events", args=b""),
|
||||
metadata=METADATA,
|
||||
)
|
||||
resp = self.timer.Once(pb.TimerOnceRequest(delay_ms=100, callback_id="ai-test-callback"), metadata=METADATA)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(resp.error)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(next, stream)
|
||||
try:
|
||||
event = future.result(timeout=5)
|
||||
except TimeoutError:
|
||||
stream.cancel()
|
||||
raise RuntimeError("timed out waiting for timer event")
|
||||
|
||||
payload = json.loads(event.result.decode("utf-8"))
|
||||
if payload.get("event") != "timer" or payload.get("callback_id") != "ai-test-callback":
|
||||
raise RuntimeError(f"unexpected payload: {payload}")
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def permission_denial(self):
|
||||
try:
|
||||
self.network.Available(pb.Empty(), metadata=METADATA)
|
||||
except grpc.RpcError as exc:
|
||||
if exc.code() == grpc.StatusCode.PERMISSION_DENIED:
|
||||
return exc.details()
|
||||
raise RuntimeError(f"wrong error: {exc.code().name} {exc.details()}")
|
||||
raise RuntimeError("network.available unexpectedly succeeded")
|
||||
|
||||
def run(self):
|
||||
print(f"AI Test Plugin connecting to {ADDR}")
|
||||
print(f"Scratch dir: {self.scratch_dir}")
|
||||
self.check("register", self.register)
|
||||
self.check("display.text", self.display_text)
|
||||
self.check("memory roundtrip", self.memory_roundtrip)
|
||||
self.check("fs roundtrip", self.fs_roundtrip)
|
||||
self.check("process roundtrip", self.process_roundtrip)
|
||||
self.check("timer event", self.timer_event)
|
||||
self.check("permission denial", self.permission_denial)
|
||||
|
||||
ok = all(r["ok"] for r in self.results)
|
||||
summary = {"plugin": PLUGIN_ID, "ok": ok, "results": self.results}
|
||||
print("AI_TEST_SUMMARY " + json.dumps(summary, ensure_ascii=False))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def main():
|
||||
runner = CheckRunner()
|
||||
try:
|
||||
return runner.run()
|
||||
finally:
|
||||
runner.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
31
plugins/ai_test/plugin.yaml
Normal file
31
plugins/ai_test/plugin.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
id: ai-test
|
||||
name: AI Test Plugin
|
||||
version: 0.1.0
|
||||
type: basic
|
||||
|
||||
# Manual verification plugin. Future plugin loaders must skip it by default.
|
||||
enabled: false
|
||||
autoload: false
|
||||
|
||||
syscalls:
|
||||
- display.text
|
||||
- memory.write
|
||||
- memory.read
|
||||
- memory.list
|
||||
- memory.delete
|
||||
- fs.write
|
||||
- fs.read
|
||||
- fs.stat
|
||||
- fs.list
|
||||
- fs.rename
|
||||
- fs.delete
|
||||
- process.spawn
|
||||
- process.stdout
|
||||
- process.wait
|
||||
- timer.once
|
||||
|
||||
exports:
|
||||
- name: run_checks
|
||||
description: Run end-to-end Agentsd syscall checks for AI verification
|
||||
|
||||
entry: python ai_test_plugin.py
|
||||
312
plugins/claudecode/claudecode_plugin.py
Normal file
312
plugins/claudecode/claudecode_plugin.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Claude Code Plugin for Agentsd
|
||||
==============================
|
||||
|
||||
Bridges locally installed Claude Code CLI into agentsd as a standard plugin.
|
||||
Enables agentsd to use Claude Code for code generation, editing, and reasoning.
|
||||
|
||||
Architecture:
|
||||
agentsd (Rust) ←gRPC→ claudecode_plugin.py → claude CLI (subprocess)
|
||||
|
||||
Capabilities:
|
||||
- chat: Send a prompt to Claude Code, get response
|
||||
- code: Generate/edit code with full Claude Code capabilities
|
||||
- session: Resume previous Claude Code sessions
|
||||
- tools: Let Claude Code use its built-in tools (Bash, Edit, Read, etc.)
|
||||
|
||||
Usage:
|
||||
# Start agentsd first, then:
|
||||
python claudecode_plugin.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
|
||||
# Add SDK path
|
||||
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
|
||||
sys.path.insert(0, str(SDK_DIR))
|
||||
|
||||
import agentsd_pb2 as pb
|
||||
import agentsd_pb2_grpc as rpc
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="[claudecode] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Find claude CLI
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", None)
|
||||
|
||||
|
||||
def find_claude():
|
||||
"""Verify claude CLI is available and return the path."""
|
||||
global CLAUDE_BIN
|
||||
|
||||
if CLAUDE_BIN is None:
|
||||
# Auto-detect
|
||||
candidates = [
|
||||
"claude",
|
||||
"claude.cmd",
|
||||
os.path.expandvars(r"%APPDATA%\npm\claude.cmd"),
|
||||
os.path.expanduser("~/.local/bin/claude"),
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
for c in candidates:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[c, "--version"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
CLAUDE_BIN = c
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
continue
|
||||
|
||||
if CLAUDE_BIN is None:
|
||||
logger.error("Claude Code CLI not found")
|
||||
logger.error("Install: npm install -g @anthropic-ai/claude-code")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[CLAUDE_BIN, "--version"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
logger.info("Found Claude Code: %s at %s", version, CLAUDE_BIN)
|
||||
return version
|
||||
except Exception as e:
|
||||
logger.error("Claude Code CLI error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class ClaudeCodePlugin:
|
||||
"""Bridges Claude Code CLI into agentsd."""
|
||||
|
||||
def __init__(self, agentsd_addr: str = "localhost:50051"):
|
||||
self.addr = agentsd_addr
|
||||
self.channel = None
|
||||
self.bridge = None
|
||||
self.version = None
|
||||
self.default_cwd = os.getcwd()
|
||||
|
||||
def connect(self):
|
||||
"""Connect to agentsd."""
|
||||
self.channel = grpc.insecure_channel(self.addr)
|
||||
self.bridge = rpc.PluginBridgeStub(self.channel)
|
||||
logger.info("Connected to agentsd at %s", self.addr)
|
||||
|
||||
def register(self):
|
||||
"""Register with agentsd."""
|
||||
resp = self.bridge.Register(pb.PluginInfo(
|
||||
id="claudecode",
|
||||
name="Claude Code",
|
||||
version=self.version or "unknown",
|
||||
plugin_type="standard",
|
||||
syscalls=["process.spawn", "display.text", "fs.read", "fs.write", "network.request"],
|
||||
depends=["hermes"],
|
||||
exports=[
|
||||
pb.ExportDef(name="chat", description="Send prompt to Claude Code, get text response"),
|
||||
pb.ExportDef(name="code", description="Generate or edit code with Claude Code"),
|
||||
pb.ExportDef(name="review", description="Review code with Claude Code"),
|
||||
pb.ExportDef(name="ask", description="Ask Claude Code a question about the codebase"),
|
||||
pb.ExportDef(name="run", description="Run Claude Code with full tool access"),
|
||||
],
|
||||
))
|
||||
if resp.ok:
|
||||
logger.info("Registered with agentsd: session=%s", resp.session_id)
|
||||
else:
|
||||
logger.error("Registration failed: %s", resp.error)
|
||||
return resp.ok
|
||||
|
||||
def call_claude(self, prompt: str, cwd: str = None, max_turns: int = None,
|
||||
allowed_tools: list = None, system_prompt: str = None,
|
||||
output_format: str = "text") -> dict:
|
||||
"""
|
||||
Call Claude Code CLI in print mode.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send
|
||||
cwd: Working directory for Claude Code
|
||||
max_turns: Max agentic turns (default: unlimited)
|
||||
allowed_tools: List of tools to allow (e.g. ["Bash", "Read", "Edit"])
|
||||
system_prompt: Override system prompt
|
||||
output_format: "text" or "json" or "stream-json"
|
||||
|
||||
Returns:
|
||||
dict with "result" or "error"
|
||||
"""
|
||||
cmd = [CLAUDE_BIN, "-p", "--output-format", output_format]
|
||||
|
||||
if max_turns is not None:
|
||||
cmd.extend(["--max-turns", str(max_turns)])
|
||||
|
||||
if allowed_tools:
|
||||
cmd.extend(["--allowedTools"] + allowed_tools)
|
||||
|
||||
if system_prompt:
|
||||
cmd.extend(["--system-prompt", system_prompt])
|
||||
|
||||
work_dir = cwd or self.default_cwd
|
||||
|
||||
logger.info("Calling claude: cwd=%s prompt=%s...", work_dir, prompt[:60])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=work_dir,
|
||||
timeout=300, # 5 min max
|
||||
env={**os.environ, "CLAUDE_CODE_SIMPLE": "1"},
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip()
|
||||
if output_format == "json":
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"result": output}
|
||||
return {"result": output}
|
||||
else:
|
||||
error = result.stderr.strip() or f"exit code {result.returncode}"
|
||||
return {"error": error}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "timeout (300s)"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def handle_call(self, fn_name: str, args: dict) -> dict:
|
||||
"""Route a call to the appropriate handler."""
|
||||
prompt = args.get("prompt", args.get("text", ""))
|
||||
cwd = args.get("cwd", self.default_cwd)
|
||||
|
||||
if fn_name == "chat":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
max_turns=1, # Single turn, no tools
|
||||
allowed_tools=[],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "code":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
allowed_tools=["Bash", "Read", "Edit", "Write", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "review":
|
||||
review_prompt = f"Review the following code or changes. Be concise and actionable:\n\n{prompt}"
|
||||
return self.call_claude(
|
||||
prompt=review_prompt,
|
||||
cwd=cwd,
|
||||
max_turns=1,
|
||||
allowed_tools=["Read", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "ask":
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
max_turns=3,
|
||||
allowed_tools=["Read", "Glob", "Grep"],
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
elif fn_name == "run":
|
||||
# Full access mode
|
||||
return self.call_claude(
|
||||
prompt=prompt,
|
||||
cwd=cwd,
|
||||
output_format="text",
|
||||
)
|
||||
|
||||
else:
|
||||
return {"error": f"unknown function: {fn_name}"}
|
||||
|
||||
def run_server(self, port: int = 50053):
|
||||
"""Run callback gRPC server for agentsd."""
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
|
||||
rpc.add_PluginBridgeServicer_to_server(ClaudeCodeServicer(self), server)
|
||||
server.add_insecure_port(f"[::]:{port}")
|
||||
server.start()
|
||||
logger.info("Claude Code plugin server listening on port %d", port)
|
||||
return server
|
||||
|
||||
|
||||
class ClaudeCodeServicer(rpc.PluginBridgeServicer):
|
||||
"""gRPC servicer for Claude Code plugin."""
|
||||
|
||||
def __init__(self, plugin: ClaudeCodePlugin):
|
||||
self.plugin = plugin
|
||||
|
||||
def Call(self, request, context):
|
||||
fn_name = request.fn
|
||||
try:
|
||||
args = json.loads(request.args) if request.args else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
|
||||
result = self.plugin.handle_call(fn_name, args)
|
||||
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
if "error" in result and not result.get("result"):
|
||||
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
|
||||
return pb.CallResponse(result=result_bytes, ok=True, error="")
|
||||
|
||||
def Register(self, request, context):
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
return pb.RegisterResponse(ok=False, error="not a bridge")
|
||||
|
||||
def Heartbeat(self, request, context):
|
||||
return pb.HeartbeatResponse(ok=True)
|
||||
|
||||
def StreamCall(self, request, context):
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
return pb.CallResponse(ok=False, error="not implemented")
|
||||
|
||||
|
||||
def main():
|
||||
# Verify claude CLI
|
||||
version = find_claude()
|
||||
if not version:
|
||||
sys.exit(1)
|
||||
|
||||
plugin = ClaudeCodePlugin()
|
||||
plugin.version = version
|
||||
|
||||
# Connect and register
|
||||
plugin.connect()
|
||||
if not plugin.register():
|
||||
sys.exit(1)
|
||||
|
||||
# Start callback server
|
||||
server = plugin.run_server(port=50053)
|
||||
|
||||
logger.info("Claude Code plugin running (v%s). Exports: chat, code, review, ask, run", version)
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
server.stop(grace=5)
|
||||
plugin.channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
plugins/claudecode/plugin.yaml
Normal file
29
plugins/claudecode/plugin.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
id: claudecode
|
||||
name: Claude Code
|
||||
version: 2.1.169
|
||||
type: standard
|
||||
|
||||
syscalls:
|
||||
- process.spawn
|
||||
- display.text
|
||||
- fs.read
|
||||
- fs.write
|
||||
- network.request
|
||||
|
||||
depends:
|
||||
- hermes # 可选,用于 agent 编排
|
||||
|
||||
exports:
|
||||
- name: chat
|
||||
description: Send prompt to Claude Code, get text response (no tools)
|
||||
- name: code
|
||||
description: Generate or edit code with Claude Code (Bash/Read/Edit/Write)
|
||||
- name: review
|
||||
description: Review code changes (Read/Glob/Grep only)
|
||||
- name: ask
|
||||
description: Ask about the codebase (Read/Glob/Grep, max 3 turns)
|
||||
- name: run
|
||||
description: Full Claude Code access (all tools, unlimited turns)
|
||||
|
||||
entry: python claudecode_plugin.py
|
||||
port: 50053
|
||||
29
plugins/claudecode/test_chat.py
Normal file
29
plugins/claudecode/test_chat.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Quick test: call Claude Code plugin's chat export."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "_sdk"))
|
||||
|
||||
import grpc
|
||||
import agentsd_pb2 as pb
|
||||
import agentsd_pb2_grpc as rpc
|
||||
|
||||
|
||||
def main():
|
||||
# Connect to claudecode plugin's callback server
|
||||
channel = grpc.insecure_channel("localhost:50053")
|
||||
bridge = rpc.PluginBridgeStub(channel)
|
||||
|
||||
# Call "chat"
|
||||
args = json.dumps({"prompt": "What is 2+2? Reply with just the number."}).encode()
|
||||
resp = bridge.Call(pb.CallRequest(target="claudecode", fn="chat", args=args))
|
||||
print(f"ok={resp.ok}")
|
||||
if resp.result:
|
||||
result = json.loads(resp.result)
|
||||
print(f"result: {result.get('result', result.get('error', ''))}")
|
||||
channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
plugins/echo/echo_plugin.py
Normal file
67
plugins/echo/echo_plugin.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Echo plugin for agentsd - verifies the gRPC syscall chain works.
|
||||
Registers as a basic plugin, calls Display.Text and FS.Read.
|
||||
"""
|
||||
import grpc
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add SDK path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_sdk"))
|
||||
|
||||
from agentsd_pb2 import (
|
||||
DisplayTextRequest, FsReadRequest, FsPathRequest,
|
||||
MemoryWriteRequest, MemoryReadRequest,
|
||||
PluginInfo, ExportDef, Empty
|
||||
)
|
||||
from agentsd_pb2_grpc import (
|
||||
DisplayStub, FSStub, MemoryStub, PluginBridgeStub
|
||||
)
|
||||
|
||||
|
||||
PLUGIN_ID = "echo-test"
|
||||
METADATA = (("x-plugin-id", PLUGIN_ID),)
|
||||
|
||||
|
||||
def main():
|
||||
channel = grpc.insecure_channel('localhost:50051')
|
||||
|
||||
# Register plugin
|
||||
bridge = PluginBridgeStub(channel)
|
||||
resp = bridge.Register(PluginInfo(
|
||||
id=PLUGIN_ID,
|
||||
name="Echo Test Plugin",
|
||||
version="0.1.0",
|
||||
plugin_type="basic",
|
||||
syscalls=["display.text", "fs.list", "memory.read", "memory.write"],
|
||||
depends=[],
|
||||
exports=[ExportDef(name="echo", description="Echo test")]
|
||||
))
|
||||
print(f"Register: ok={resp.ok} session={resp.session_id}")
|
||||
|
||||
# Test Display
|
||||
display = DisplayStub(channel)
|
||||
resp = display.Text(DisplayTextRequest(content="Hello from echo plugin!"), metadata=METADATA)
|
||||
print(f"Display.Text: ok={resp.ok}")
|
||||
|
||||
# Test Memory write + read
|
||||
memory = MemoryStub(channel)
|
||||
resp = memory.Write(MemoryWriteRequest(key="test.greeting", value=b"Hello World"), metadata=METADATA)
|
||||
print(f"Memory.Write: ok={resp.ok}")
|
||||
|
||||
resp = memory.Read(MemoryReadRequest(key="test.greeting"), metadata=METADATA)
|
||||
print(f"Memory.Read: found={resp.found} value={resp.value.decode()}")
|
||||
|
||||
# Test FS list
|
||||
fs = FSStub(channel)
|
||||
resp = fs.List(FsPathRequest(path="."), metadata=METADATA)
|
||||
print(f"FS.List: ok={resp.ok} entries={len(resp.entries)}")
|
||||
for entry in resp.entries[:5]:
|
||||
print(f" {entry.name} {'[dir]' if entry.is_dir else ''}")
|
||||
|
||||
print("\nAll syscalls verified OK!")
|
||||
channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
plugins/echo/plugin.yaml
Normal file
16
plugins/echo/plugin.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
id: echo-test
|
||||
name: Echo Test Plugin
|
||||
version: 0.1.0
|
||||
type: basic
|
||||
|
||||
syscalls:
|
||||
- display.text
|
||||
- fs.list
|
||||
- memory.read
|
||||
- memory.write
|
||||
|
||||
exports:
|
||||
- name: echo
|
||||
description: Echo test
|
||||
|
||||
entry: python echo_plugin.py
|
||||
215
plugins/hermes/hermes_plugin.py
Normal file
215
plugins/hermes/hermes_plugin.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Hermes Plugin for Agentsd
|
||||
=========================
|
||||
|
||||
Bridges Hermes Agent's tool registry into agentsd as a standard plugin.
|
||||
All Hermes tools become callable via agentsd's gRPC PluginBridge.
|
||||
|
||||
Architecture:
|
||||
agentsd (Rust) ←gRPC→ hermes-plugin.py ← Hermes tools/registry.py
|
||||
|
||||
This plugin:
|
||||
1. Imports Hermes tool registry and discovers all built-in tools
|
||||
2. Registers with agentsd as a "basic" plugin, exporting all Hermes tools
|
||||
3. Listens for Call requests from agentsd and dispatches to Hermes handlers
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent import futures
|
||||
from pathlib import Path
|
||||
|
||||
import grpc
|
||||
from grpc import StatusCode
|
||||
|
||||
# Add hermes-agent to path
|
||||
HERMES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "hermes-agent"
|
||||
sys.path.insert(0, str(HERMES_DIR))
|
||||
|
||||
# Import generated proto
|
||||
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
|
||||
sys.path.insert(0, str(SDK_DIR))
|
||||
|
||||
import agentsd_pb2 as pb
|
||||
import agentsd_pb2_grpc as rpc
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="[hermes-plugin] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HermesPlugin:
|
||||
"""Bridges Hermes tools into agentsd."""
|
||||
|
||||
def __init__(self, agentsd_addr: str = "localhost:50051"):
|
||||
self.addr = agentsd_addr
|
||||
self.channel = None
|
||||
self.bridge = None
|
||||
self.registry = None
|
||||
self.tool_handlers = {}
|
||||
|
||||
def load_hermes_tools(self):
|
||||
"""Import and discover all Hermes built-in tools."""
|
||||
logger.info("Loading Hermes tools from: %s", HERMES_DIR)
|
||||
|
||||
# Import registry
|
||||
from tools.registry import registry, discover_builtin_tools
|
||||
|
||||
# Discover and import all tool modules
|
||||
imported = discover_builtin_tools(HERMES_DIR / "tools")
|
||||
logger.info("Imported %d tool modules", len(imported))
|
||||
|
||||
self.registry = registry
|
||||
|
||||
# Collect all tool names and handlers
|
||||
all_names = registry.get_all_tool_names()
|
||||
for name in all_names:
|
||||
entry = registry.get_entry(name)
|
||||
if entry:
|
||||
self.tool_handlers[name] = entry
|
||||
logger.info("Loaded %d tools: %s", len(self.tool_handlers), list(self.tool_handlers.keys())[:10])
|
||||
return all_names
|
||||
|
||||
def connect(self):
|
||||
"""Connect to agentsd gRPC server."""
|
||||
self.channel = grpc.insecure_channel(self.addr)
|
||||
self.bridge = rpc.PluginBridgeStub(self.channel)
|
||||
logger.info("Connected to agentsd at %s", self.addr)
|
||||
|
||||
def register(self, tool_names):
|
||||
"""Register this plugin with agentsd, exporting all Hermes tools."""
|
||||
exports = []
|
||||
for name in tool_names:
|
||||
entry = self.tool_handlers.get(name)
|
||||
desc = entry.description if entry else ""
|
||||
exports.append(pb.ExportDef(name=name, description=desc[:200]))
|
||||
|
||||
resp = self.bridge.Register(pb.PluginInfo(
|
||||
id="hermes",
|
||||
name="Hermes Agent Tools",
|
||||
version="0.16.0",
|
||||
plugin_type="basic",
|
||||
syscalls=["network.request", "fs.read", "fs.write", "process.spawn", "memory.read", "memory.write", "display.text", "timer.once"],
|
||||
depends=[],
|
||||
exports=exports,
|
||||
))
|
||||
|
||||
if resp.ok:
|
||||
logger.info("Registered with agentsd: session=%s, %d exports", resp.session_id, len(exports))
|
||||
else:
|
||||
logger.error("Registration failed: %s", resp.error)
|
||||
return resp.ok
|
||||
|
||||
def call_tool(self, tool_name: str, args: dict) -> dict:
|
||||
"""Execute a Hermes tool by name with given args."""
|
||||
entry = self.tool_handlers.get(tool_name)
|
||||
if not entry:
|
||||
return {"error": f"tool not found: {tool_name}"}
|
||||
|
||||
try:
|
||||
if entry.is_async:
|
||||
# Run async handler in event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
result = loop.run_until_complete(entry.handler(args))
|
||||
finally:
|
||||
loop.close()
|
||||
else:
|
||||
result = entry.handler(args)
|
||||
|
||||
# Hermes tools return strings or dicts
|
||||
if isinstance(result, str):
|
||||
return {"result": result}
|
||||
elif isinstance(result, dict):
|
||||
return result
|
||||
else:
|
||||
return {"result": str(result)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Tool %s failed: %s", tool_name, e)
|
||||
return {"error": str(e)}
|
||||
|
||||
def run_server(self, port: int = 50052):
|
||||
"""
|
||||
Run a gRPC server that agentsd can call back into.
|
||||
Agentsd routes Call requests here for Hermes tools.
|
||||
"""
|
||||
from concurrent import futures
|
||||
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
|
||||
rpc.add_PluginBridgeServicer_to_server(HermesServicer(self), server)
|
||||
server.add_insecure_port(f"[::]:{port}")
|
||||
server.start()
|
||||
logger.info("Hermes plugin server listening on port %d", port)
|
||||
return server
|
||||
|
||||
|
||||
class HermesServicer(rpc.PluginBridgeServicer):
|
||||
"""gRPC servicer that handles tool call requests from agentsd."""
|
||||
|
||||
def __init__(self, plugin: HermesPlugin):
|
||||
self.plugin = plugin
|
||||
|
||||
def Call(self, request, context):
|
||||
"""Handle a tool call from agentsd."""
|
||||
fn_name = request.fn
|
||||
try:
|
||||
args = json.loads(request.args) if request.args else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
|
||||
result = self.plugin.call_tool(fn_name, args)
|
||||
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
if "error" in result and not result.get("result"):
|
||||
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
|
||||
return pb.CallResponse(result=result_bytes, ok=True, error="")
|
||||
|
||||
def Register(self, request, context):
|
||||
context.set_code(StatusCode.UNIMPLEMENTED)
|
||||
return pb.RegisterResponse(ok=False, error="not a bridge")
|
||||
|
||||
def Heartbeat(self, request, context):
|
||||
return pb.HeartbeatResponse(ok=True)
|
||||
|
||||
def StreamCall(self, request, context):
|
||||
context.set_code(StatusCode.UNIMPLEMENTED)
|
||||
return pb.CallResponse(ok=False, error="not implemented")
|
||||
|
||||
|
||||
def main():
|
||||
plugin = HermesPlugin()
|
||||
|
||||
# Load all Hermes tools
|
||||
try:
|
||||
tool_names = plugin.load_hermes_tools()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load Hermes tools: %s", e)
|
||||
logger.info("Ensure hermes-agent is at: %s", HERMES_DIR)
|
||||
sys.exit(1)
|
||||
|
||||
# Connect and register with agentsd
|
||||
plugin.connect()
|
||||
if not plugin.register(tool_names):
|
||||
sys.exit(1)
|
||||
|
||||
# Start callback server for tool invocations
|
||||
server = plugin.run_server(port=50052)
|
||||
|
||||
logger.info("Hermes plugin running. %d tools available. Ctrl+C to stop.", len(tool_names))
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
server.stop(grace=5)
|
||||
plugin.channel.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
plugins/hermes/plugin.yaml
Normal file
19
plugins/hermes/plugin.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
id: hermes
|
||||
name: Hermes Agent Tools
|
||||
version: 0.16.0
|
||||
type: basic
|
||||
|
||||
syscalls:
|
||||
- network.request
|
||||
- fs.read
|
||||
- fs.write
|
||||
- process.spawn
|
||||
- memory.read
|
||||
- memory.write
|
||||
- display.text
|
||||
- timer.once
|
||||
|
||||
exports: auto # 自动从 Hermes registry 发现
|
||||
|
||||
entry: python hermes_plugin.py
|
||||
port: 50052
|
||||
217
proto/agentsd.proto
Normal file
217
proto/agentsd.proto
Normal 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 {}
|
||||
Reference in New Issue
Block a user