feat(plugins): scaffold wasm runtime and wire core hook lifecycle

This commit is contained in:
argenis de la rosa 2026-02-28 19:45:51 -05:00 committed by Argenis
parent c3dbd9a7a7
commit 1d6afe792b
18 changed files with 1021 additions and 6 deletions

View File

@ -0,0 +1,152 @@
# WASM Plugin Runtime Design (Capability-Segmented, WASI Preview 2)
## Context
ZeroClaw currently uses in-process trait/factory extension points for providers, tools, channels, memory, runtime adapters, observers, peripherals, and hooks. Hook interfaces exist, but several lifecycle events are either missing or not wired in runtime paths.
## Objective
Design and implement a production-safe system WASM plugin runtime that supports:
- hook plugins
- tool plugins
- provider plugins
- `BeforeCompaction` / `AfterCompaction` hook points
- `ToolResultPersist` modifying hook
- `ObserverBridge` (legacy observer -> hook adapter)
- `fire_gateway_stop` runtime wiring
- built-in `session_memory` and `boot_script` hooks
- hot-reload without service restart
## Chosen Direction
Capability-segmented plugin API on WASI Preview 2 + WIT.
Why:
- cleaner authoring surface than a monolithic plugin ABI
- stronger permission boundaries per capability
- easier long-term compatibility/versioning
- lower blast radius for failures and upgrades
## Architecture
### 1. Plugin Subsystem
Add `src/plugins/` as first-class subsystem:
- `src/plugins/mod.rs`
- `src/plugins/traits.rs`
- `src/plugins/manifest.rs`
- `src/plugins/runtime.rs`
- `src/plugins/registry.rs`
- `src/plugins/hot_reload.rs`
- `src/plugins/bridge/observer.rs`
### 2. WIT Contracts
Define separate contracts under `wit/zeroclaw/`:
- `hooks/v1`
- `tools/v1`
- `providers/v1`
Each contract has independent semver policy and compatibility checks.
### 3. Capability Model
Manifest-declared capabilities are deny-by-default.
Host grants capability-specific rights through config policy.
Examples:
- `hooks`
- `tools.execute`
- `providers.chat`
- optional I/O scopes (network/fs/secrets) via explicit allowlists
### 4. Runtime Lifecycle
1. Discover plugin manifests in configured directories.
2. Validate metadata (ABI version, checksum/signature policy, capabilities).
3. Instantiate plugin runtime components in immutable snapshot.
4. Register plugin-provided hook handlers, tools, and providers.
5. Atomically publish snapshot.
### 5. Dispatch Model
#### Hooks
- Void hooks: bounded parallel fanout + timeout.
- Modifying hooks: deterministic ordered pipeline (priority desc, stable plugin-id tie-breaker).
#### Tools
- Merge native and plugin tool specs.
- Route tool calls by ownership.
- Keep host-side security policy enforcement before plugin execution.
- Apply `ToolResultPersist` modifying hook before final persistence and feedback.
#### Providers
- Extend provider factory lookup to include plugin provider registry.
- Plugin providers participate in existing resilience and routing wrappers.
### 6. New Hook Points
Add and wire:
- `BeforeCompaction`
- `AfterCompaction`
- `ToolResultPersist`
- `fire_gateway_stop` call site on graceful gateway shutdown
### 7. Built-in Hooks
Provide built-ins loaded through same hook registry:
- `session_memory`
- `boot_script`
This keeps runtime behavior consistent between native and plugin hooks.
### 8. ObserverBridge
Add adapter that maps observer events into hook events, preserving legacy observer flows while enabling hook-based plugin processing.
### 9. Hot Reload
- Watch plugin files/manifests.
- Rebuild and validate candidate snapshot fully.
- Atomic swap on success.
- Keep old snapshot if reload fails.
- In-flight invocations continue on the snapshot they started with.
## Safety and Reliability
- Per-plugin memory/CPU/time/concurrency limits.
- Invocation timeout and trap isolation.
- Circuit breaker for repeatedly failing plugins.
- No plugin error may crash core runtime path.
- Sensitive payload redaction at host observability boundary.
## Compatibility Strategy
- Independent major-version compatibility checks per WIT package.
- Reject incompatible plugins at load time with clear diagnostics.
- Preserve native implementations as fallback path.
## Testing Strategy
### Unit
- manifest parsing and capability policy
- ABI compatibility checks
- hook ordering and cancellation semantics
- timeout/trap handling
### Integration
- plugin tool registration/execution
- plugin provider routing + fallback
- compaction hook sequence
- gateway stop hook firing
- hot-reload swap/rollback behavior
### Regression
- native-only mode unchanged when plugins disabled
- security policy enforcement remains intact
## Rollout Plan
1. Foundation: subsystem + config + ABI skeleton.
2. Hook integration + new hook points + built-ins.
3. Tool plugin routing.
4. Provider plugin routing.
5. Hot reload + ObserverBridge.
6. SDK + docs + example plugins.
## Non-goals (v1)
- dynamic cross-plugin dependency resolution
- distributed remote plugin registries
- automatic plugin marketplace installation
## Risks
- ABI churn if contracts are not tightly scoped.
- runtime overhead with poorly bounded plugin execution.
- operational complexity from hot-reload races.
## Mitigations
- capability segmentation + strict semver.
- hard limits and circuit breakers.
- immutable snapshot architecture for reload safety.

View File

@ -0,0 +1,379 @@
# WASM Plugin Runtime Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a WASI Preview 2 + WIT plugin runtime that supports hook/tool/provider plugins, new hook points, ObserverBridge, and hot-reload with safe fallback.
**Architecture:** Add a capability-segmented plugin subsystem (`src/plugins/**`) and route hook/tool/provider dispatch through immutable plugin snapshots. Keep native implementations intact as fallback. Enforce deny-by-default capability policy with host-side limits and deterministic modifying-hook ordering.
**Tech Stack:** Rust, Tokio, Wasmtime (component model), WASI Preview 2, WIT, serde, notify, existing ZeroClaw traits/factories.
---
### Task 1: Add plugin config schema and defaults
**Files:**
- Modify: `src/config/schema.rs`
- Modify: `src/config/mod.rs`
- Test: `src/config/schema.rs` (inline tests)
**Step 1: Write the failing test**
```rust
#[test]
fn plugins_config_defaults_safe() {
let cfg = HooksConfig::default();
// replace with PluginConfig once added
assert!(cfg.enabled);
}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked config::schema -- --nocapture`
Expected: FAIL because `PluginsConfig` fields/assertions do not exist yet.
**Step 3: Write minimal implementation**
- Add `PluginsConfig` with:
- `enabled: bool`
- `dirs: Vec<String>`
- `hot_reload: bool`
- `limits` (timeout/memory/concurrency)
- capability allow/deny lists
- Add defaults: disabled-by-default runtime loading, deny-by-default capabilities.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked config::schema -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/config/schema.rs src/config/mod.rs
git commit -m "feat(config): add plugin runtime config schema"
```
### Task 2: Scaffold plugin subsystem modules
**Files:**
- Create: `src/plugins/mod.rs`
- Create: `src/plugins/traits.rs`
- Create: `src/plugins/manifest.rs`
- Create: `src/plugins/runtime.rs`
- Create: `src/plugins/registry.rs`
- Create: `src/plugins/hot_reload.rs`
- Create: `src/plugins/bridge/mod.rs`
- Create: `src/plugins/bridge/observer.rs`
- Modify: `src/lib.rs`
- Test: inline tests in new modules
**Step 1: Write the failing test**
```rust
#[test]
fn plugin_registry_empty_by_default() {
let reg = PluginRegistry::default();
assert!(reg.hooks().is_empty());
}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked plugins:: -- --nocapture`
Expected: FAIL because modules/types do not exist.
**Step 3: Write minimal implementation**
- Add module exports and basic structs/enums.
- Keep runtime no-op while preserving compile-time interfaces.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked plugins:: -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/plugins src/lib.rs
git commit -m "feat(plugins): scaffold plugin subsystem modules"
```
### Task 3: Add WIT capability contracts and ABI version checks
**Files:**
- Create: `wit/zeroclaw/hooks/v1/*.wit`
- Create: `wit/zeroclaw/tools/v1/*.wit`
- Create: `wit/zeroclaw/providers/v1/*.wit`
- Modify: `src/plugins/manifest.rs`
- Test: `src/plugins/manifest.rs` inline tests
**Step 1: Write the failing test**
```rust
#[test]
fn manifest_rejects_incompatible_wit_major() {
let m = PluginManifest { wit_package: "zeroclaw:hooks@2.0.0".into(), ..Default::default() };
assert!(validate_manifest(&m).is_err());
}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked manifest_rejects_incompatible_wit_major -- --nocapture`
Expected: FAIL before validator exists.
**Step 3: Write minimal implementation**
- Add WIT package declarations and version policy parser.
- Validate major compatibility per capability package.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked manifest_rejects_incompatible_wit_major -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add wit src/plugins/manifest.rs
git commit -m "feat(plugins): add wit contracts and abi compatibility checks"
```
### Task 4: Hook runtime integration and missing lifecycle wiring
**Files:**
- Modify: `src/hooks/traits.rs`
- Modify: `src/hooks/runner.rs`
- Modify: `src/gateway/mod.rs`
- Modify: `src/agent/loop_.rs`
- Modify: `src/channels/mod.rs`
- Test: inline tests in `src/hooks/runner.rs`, `src/agent/loop_.rs`
**Step 1: Write the failing test**
```rust
#[tokio::test]
async fn fire_gateway_stop_is_called_on_shutdown_path() {
// assert hook observed stop signal
}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked fire_gateway_stop_is_called_on_shutdown_path -- --nocapture`
Expected: FAIL due to missing call site.
**Step 3: Write minimal implementation**
- Add hook events: `BeforeCompaction`, `AfterCompaction`, `ToolResultPersist`.
- Wire `fire_gateway_stop` in graceful shutdown path.
- Trigger compaction hooks around compaction flows.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked hooks::runner -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/hooks src/gateway/mod.rs src/agent/loop_.rs src/channels/mod.rs
git commit -m "feat(hooks): add compaction/persist hooks and gateway stop lifecycle wiring"
```
### Task 5: Implement built-in `session_memory` and `boot_script` hooks
**Files:**
- Create: `src/hooks/builtin/session_memory.rs`
- Create: `src/hooks/builtin/boot_script.rs`
- Modify: `src/hooks/builtin/mod.rs`
- Modify: `src/config/schema.rs`
- Modify: `src/agent/loop_.rs`
- Modify: `src/channels/mod.rs`
- Test: inline tests in new builtins
**Step 1: Write the failing test**
```rust
#[tokio::test]
async fn session_memory_hook_persists_and_recalls_expected_context() {}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked session_memory_hook -- --nocapture`
Expected: FAIL before hook exists.
**Step 3: Write minimal implementation**
- Register both built-ins through `HookRunner` initialization paths.
- `session_memory`: persist/retrieve session-scoped summaries.
- `boot_script`: mutate prompt/context at startup/session begin.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked hooks::builtin -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/hooks/builtin src/config/schema.rs src/agent/loop_.rs src/channels/mod.rs
git commit -m "feat(hooks): add session_memory and boot_script built-in hooks"
```
### Task 6: Add plugin tool registration and execution routing
**Files:**
- Modify: `src/tools/mod.rs`
- Modify: `src/tools/traits.rs`
- Modify: `src/agent/loop_.rs`
- Modify: `src/plugins/registry.rs`
- Modify: `src/plugins/runtime.rs`
- Test: `src/agent/loop_.rs` inline tests, `src/tools/mod.rs` tests
**Step 1: Write the failing test**
```rust
#[tokio::test]
async fn plugin_tool_spec_is_visible_and_executable() {}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked plugin_tool_spec_is_visible_and_executable -- --nocapture`
Expected: FAIL before routing exists.
**Step 3: Write minimal implementation**
- Merge plugin tool specs with native specs.
- Route execution by owner.
- Keep host security checks before plugin invocation.
- Apply `ToolResultPersist` before persistence/feedback.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked agent::loop_ -- --nocapture`
Expected: PASS for plugin tool tests.
**Step 5: Commit**
```bash
git add src/tools/mod.rs src/tools/traits.rs src/agent/loop_.rs src/plugins/registry.rs src/plugins/runtime.rs
git commit -m "feat(tools): support wasm plugin tool registration and execution"
```
### Task 7: Add plugin provider registration and factory integration
**Files:**
- Modify: `src/providers/mod.rs`
- Modify: `src/providers/traits.rs`
- Modify: `src/plugins/registry.rs`
- Modify: `src/plugins/runtime.rs`
- Test: `src/providers/mod.rs` inline tests
**Step 1: Write the failing test**
```rust
#[test]
fn factory_can_create_plugin_provider() {}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked factory_can_create_plugin_provider -- --nocapture`
Expected: FAIL before plugin provider lookup exists.
**Step 3: Write minimal implementation**
- Extend provider factory to resolve plugin providers after native map.
- Ensure resilient/routed providers can wrap plugin providers.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked providers::mod -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/providers/mod.rs src/providers/traits.rs src/plugins/registry.rs src/plugins/runtime.rs
git commit -m "feat(providers): integrate wasm plugin providers into factory and routing"
```
### Task 8: Implement ObserverBridge
**Files:**
- Modify: `src/plugins/bridge/observer.rs`
- Modify: `src/observability/mod.rs`
- Modify: `src/agent/loop_.rs`
- Modify: `src/gateway/mod.rs`
- Test: `src/plugins/bridge/observer.rs` inline tests
**Step 1: Write the failing test**
```rust
#[test]
fn observer_bridge_emits_hook_events_for_legacy_observer_stream() {}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked observer_bridge_emits_hook_events_for_legacy_observer_stream -- --nocapture`
Expected: FAIL before bridge wiring.
**Step 3: Write minimal implementation**
- Implement adapter mapping observer events into hook dispatch.
- Wire where observer is created in agent/channel/gateway flows.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked plugins::bridge -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/plugins/bridge/observer.rs src/observability/mod.rs src/agent/loop_.rs src/gateway/mod.rs
git commit -m "feat(observability): add observer-to-hook bridge for plugin runtime"
```
### Task 9: Implement hot reload with immutable snapshots
**Files:**
- Modify: `src/plugins/hot_reload.rs`
- Modify: `src/plugins/registry.rs`
- Modify: `src/plugins/runtime.rs`
- Modify: `src/main.rs`
- Test: `src/plugins/hot_reload.rs` inline tests
**Step 1: Write the failing test**
```rust
#[tokio::test]
async fn reload_failure_keeps_previous_snapshot_active() {}
```
**Step 2: Run test to verify it fails**
Run: `cargo test --locked reload_failure_keeps_previous_snapshot_active -- --nocapture`
Expected: FAIL before atomic swap logic.
**Step 3: Write minimal implementation**
- File watcher rebuilds candidate snapshot.
- Validate fully before publish.
- Atomic swap on success; rollback on failure.
- Preserve in-flight snapshot handles.
**Step 4: Run test to verify it passes**
Run: `cargo test --locked plugins::hot_reload -- --nocapture`
Expected: PASS.
**Step 5: Commit**
```bash
git add src/plugins/hot_reload.rs src/plugins/registry.rs src/plugins/runtime.rs src/main.rs
git commit -m "feat(plugins): add safe hot-reload with immutable snapshot swap"
```
### Task 10: Documentation and verification pass
**Files:**
- Create: `docs/plugins-runtime.md`
- Modify: `docs/config-reference.md`
- Modify: `docs/commands-reference.md`
- Modify: `docs/troubleshooting.md`
- Modify: locale docs where equivalents exist (`fr`, `vi` minimum for config/commands/troubleshooting)
**Step 1: Write the failing doc checks**
- Define link/consistency checks and navigation parity expectations.
**Step 2: Run doc checks to verify failures (if stale links exist)**
Run: project markdown/link checks used in repo CI.
Expected: potential FAIL until docs updated.
**Step 3: Write minimal documentation updates**
- Plugin config keys, lifecycle, safety model, hot reload behavior, operator troubleshooting.
**Step 4: Run full validation**
Run:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --locked
```
Expected: PASS.
**Step 5: Commit**
```bash
git add docs src
git commit -m "docs(plugins): document wasm plugin runtime config lifecycle and operations"
```
## Final Integration Checklist
- Ensure plugins disabled mode preserves existing behavior.
- Ensure security defaults remain deny-by-default.
- Ensure hook ordering and cancellation semantics are deterministic.
- Ensure provider/tool fallback behavior is unchanged for native implementations.
- Ensure hot-reload failures are non-fatal and reversible.

View File

@ -1485,11 +1485,30 @@ pub(crate) async fn run_tool_call_loop(
// ── Hook: after_tool_call (void) ─────────────────
if let Some(hooks) = hooks {
let tool_result_obj = crate::tools::ToolResult {
let mut tool_result_obj = crate::tools::ToolResult {
success: outcome.success,
output: outcome.output.clone(),
error: None,
};
match hooks
.run_tool_result_persist(call.name.clone(), tool_result_obj.clone())
.await
{
crate::hooks::HookResult::Continue(next) => {
tool_result_obj = next;
outcome.success = tool_result_obj.success;
outcome.output = tool_result_obj.output.clone();
outcome.error_reason = tool_result_obj.error.clone();
}
crate::hooks::HookResult::Cancel(reason) => {
outcome.success = false;
outcome.error_reason = Some(scrub_credentials(&reason));
outcome.output = format!("Tool result blocked by hook: {reason}");
tool_result_obj.success = false;
tool_result_obj.error = Some(reason);
tool_result_obj.output = outcome.output.clone();
}
}
hooks
.fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
.await;
@ -2027,6 +2046,22 @@ pub async fn run(
}
system_prompt.push_str(&build_shell_policy_instructions(&config.autonomy));
let hooks: Option<std::sync::Arc<crate::hooks::HookRunner>> = if config.hooks.enabled {
let mut runner = crate::hooks::HookRunner::new();
if config.hooks.builtin.boot_script {
runner.register(Box::new(crate::hooks::builtin::BootScriptHook));
}
if config.hooks.builtin.command_logger {
runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
}
if config.hooks.builtin.session_memory {
runner.register(Box::new(crate::hooks::builtin::SessionMemoryHook));
}
Some(std::sync::Arc::new(runner))
} else {
None
};
// ── Approval manager (supervised mode) ───────────────────────
let approval_manager = if interactive {
Some(ApprovalManager::from_config(&config.autonomy))
@ -2103,7 +2138,7 @@ pub async fn run(
config.agent.max_tool_iterations,
None,
None,
hooks,
hooks.as_deref(),
&[],
),
),
@ -2280,7 +2315,7 @@ pub async fn run(
config.agent.max_tool_iterations,
None,
None,
hooks,
hooks.as_deref(),
&[],
),
),
@ -2340,6 +2375,7 @@ pub async fn run(
provider.as_ref(),
&model_name,
config.agent.max_history_messages,
hooks.as_deref(),
)
.await
{

View File

@ -66,6 +66,7 @@ pub(super) async fn auto_compact_history(
provider: &dyn Provider,
model: &str,
max_history: usize,
hooks: Option<&crate::hooks::HookRunner>,
) -> Result<bool> {
let has_system = history.first().map_or(false, |m| m.role == "system");
let non_system_count = if has_system {
@ -91,6 +92,17 @@ pub(super) async fn auto_compact_history(
compact_end += 1;
}
let to_compact: Vec<ChatMessage> = history[start..compact_end].to_vec();
let to_compact = if let Some(hooks) = hooks {
match hooks.run_before_compaction(to_compact).await {
crate::hooks::HookResult::Continue(messages) => messages,
crate::hooks::HookResult::Cancel(reason) => {
tracing::info!(%reason, "history compaction cancelled by hook");
return Ok(false);
}
}
} else {
to_compact
};
let transcript = build_compaction_transcript(&to_compact);
let summarizer_system = "You are a conversation compaction engine. Summarize older chat history into concise context for future turns. Preserve: user preferences, commitments, decisions, unresolved tasks, key facts. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only.";
@ -109,6 +121,17 @@ pub(super) async fn auto_compact_history(
});
let summary = truncate_with_ellipsis(&summary_raw, COMPACTION_MAX_SUMMARY_CHARS);
let summary = if let Some(hooks) = hooks {
match hooks.run_after_compaction(summary).await {
crate::hooks::HookResult::Continue(next_summary) => next_summary,
crate::hooks::HookResult::Cancel(reason) => {
tracing::info!(%reason, "post-compaction summary cancelled by hook");
return Ok(false);
}
}
} else {
summary
};
apply_compaction_summary(history, start, compact_end, &summary);
Ok(true)

View File

@ -5513,9 +5513,15 @@ pub async fn start_channels(config: Config) -> Result<()> {
multimodal: config.multimodal.clone(),
hooks: if config.hooks.enabled {
let mut runner = crate::hooks::HookRunner::new();
if config.hooks.builtin.boot_script {
runner.register(Box::new(crate::hooks::builtin::BootScriptHook));
}
if config.hooks.builtin.command_logger {
runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
}
if config.hooks.builtin.session_memory {
runner.register(Box::new(crate::hooks::builtin::SessionMemoryHook));
}
Some(Arc::new(runner))
} else {
None

View File

@ -366,7 +366,17 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
// ── Hooks ──────────────────────────────────────────────────────
let hooks: Option<std::sync::Arc<crate::hooks::HookRunner>> = if config.hooks.enabled {
Some(std::sync::Arc::new(crate::hooks::HookRunner::new()))
let mut runner = crate::hooks::HookRunner::new();
if config.hooks.builtin.boot_script {
runner.register(Box::new(crate::hooks::builtin::BootScriptHook));
}
if config.hooks.builtin.command_logger {
runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
}
if config.hooks.builtin.session_memory {
runner.register(Box::new(crate::hooks::builtin::SessionMemoryHook));
}
Some(std::sync::Arc::new(runner))
} else {
None
};
@ -834,11 +844,17 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.fallback(get(static_files::handle_spa_fallback));
// Run the server
axum::serve(
let serve_result = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
.await;
if let Some(ref hooks) = hooks {
hooks.fire_gateway_stop().await;
}
serve_result?;
Ok(())
}

View File

@ -0,0 +1,37 @@
use async_trait::async_trait;
use crate::hooks::traits::{HookHandler, HookResult};
/// Built-in hook for startup prompt boot-script mutation.
///
/// Current implementation is a pass-through placeholder to keep behavior stable.
pub struct BootScriptHook;
#[async_trait]
impl HookHandler for BootScriptHook {
fn name(&self) -> &str {
"boot-script"
}
fn priority(&self) -> i32 {
10
}
async fn before_prompt_build(&self, prompt: String) -> HookResult<String> {
HookResult::Continue(prompt)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn boot_script_hook_passes_prompt_through() {
let hook = BootScriptHook;
match hook.before_prompt_build("prompt".into()).await {
HookResult::Continue(next) => assert_eq!(next, "prompt"),
HookResult::Cancel(reason) => panic!("unexpected cancel: {reason}"),
}
}
}

View File

@ -1,3 +1,7 @@
pub mod boot_script;
pub mod command_logger;
pub mod session_memory;
pub use boot_script::BootScriptHook;
pub use command_logger::CommandLoggerHook;
pub use session_memory::SessionMemoryHook;

View File

@ -0,0 +1,39 @@
use async_trait::async_trait;
use crate::hooks::traits::{HookHandler, HookResult};
use crate::providers::traits::ChatMessage;
/// Built-in hook for lightweight session-memory behavior.
///
/// Current implementation is a safe no-op placeholder that preserves message flow.
pub struct SessionMemoryHook;
#[async_trait]
impl HookHandler for SessionMemoryHook {
fn name(&self) -> &str {
"session-memory"
}
fn priority(&self) -> i32 {
-10
}
async fn before_compaction(&self, messages: Vec<ChatMessage>) -> HookResult<Vec<ChatMessage>> {
HookResult::Continue(messages)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn session_memory_hook_passes_messages_through() {
let hook = SessionMemoryHook;
let messages = vec![ChatMessage::user("hello")];
match hook.before_compaction(messages.clone()).await {
HookResult::Continue(next) => assert_eq!(next.len(), 1),
HookResult::Cancel(reason) => panic!("unexpected cancel: {reason}"),
}
}
}

View File

@ -245,6 +245,91 @@ impl HookRunner {
HookResult::Continue((name, args))
}
pub async fn run_before_compaction(
&self,
mut messages: Vec<ChatMessage>,
) -> HookResult<Vec<ChatMessage>> {
for h in &self.handlers {
let hook_name = h.name();
match AssertUnwindSafe(h.before_compaction(messages.clone()))
.catch_unwind()
.await
{
Ok(HookResult::Continue(next)) => messages = next,
Ok(HookResult::Cancel(reason)) => {
info!(
hook = hook_name,
reason, "before_compaction cancelled by hook"
);
return HookResult::Cancel(reason);
}
Err(_) => {
tracing::error!(
hook = hook_name,
"before_compaction hook panicked; continuing with previous value"
);
}
}
}
HookResult::Continue(messages)
}
pub async fn run_after_compaction(&self, mut summary: String) -> HookResult<String> {
for h in &self.handlers {
let hook_name = h.name();
match AssertUnwindSafe(h.after_compaction(summary.clone()))
.catch_unwind()
.await
{
Ok(HookResult::Continue(next)) => summary = next,
Ok(HookResult::Cancel(reason)) => {
info!(
hook = hook_name,
reason, "after_compaction cancelled by hook"
);
return HookResult::Cancel(reason);
}
Err(_) => {
tracing::error!(
hook = hook_name,
"after_compaction hook panicked; continuing with previous value"
);
}
}
}
HookResult::Continue(summary)
}
pub async fn run_tool_result_persist(
&self,
tool: String,
mut result: ToolResult,
) -> HookResult<ToolResult> {
for h in &self.handlers {
let hook_name = h.name();
match AssertUnwindSafe(h.tool_result_persist(tool.clone(), result.clone()))
.catch_unwind()
.await
{
Ok(HookResult::Continue(next_result)) => result = next_result,
Ok(HookResult::Cancel(reason)) => {
info!(
hook = hook_name,
reason, "tool_result_persist cancelled by hook"
);
return HookResult::Cancel(reason);
}
Err(_) => {
tracing::error!(
hook = hook_name,
"tool_result_persist hook panicked; continuing with previous value"
);
}
}
}
HookResult::Continue(result)
}
pub async fn run_on_message_received(
&self,
mut message: ChannelMessage,

View File

@ -64,6 +64,22 @@ pub trait HookHandler: Send + Sync {
HookResult::Continue((name, args))
}
async fn before_compaction(&self, messages: Vec<ChatMessage>) -> HookResult<Vec<ChatMessage>> {
HookResult::Continue(messages)
}
async fn after_compaction(&self, summary: String) -> HookResult<String> {
HookResult::Continue(summary)
}
async fn tool_result_persist(
&self,
_tool: String,
result: ToolResult,
) -> HookResult<ToolResult> {
HookResult::Continue(result)
}
async fn on_message_received(&self, message: ChannelMessage) -> HookResult<ChannelMessage> {
HookResult::Continue(message)
}

View File

@ -0,0 +1 @@
pub mod observer;

View File

@ -0,0 +1,71 @@
use std::sync::Arc;
use crate::observability::traits::ObserverMetric;
use crate::observability::{Observer, ObserverEvent};
pub struct ObserverBridge {
inner: Arc<dyn Observer>,
}
impl ObserverBridge {
pub fn new(inner: Arc<dyn Observer>) -> Self {
Self { inner }
}
}
impl Observer for ObserverBridge {
fn record_event(&self, event: &ObserverEvent) {
self.inner.record_event(event);
}
fn record_metric(&self, metric: &ObserverMetric) {
self.inner.record_metric(metric);
}
fn flush(&self) {
self.inner.flush();
}
fn name(&self) -> &str {
"observer-bridge"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
#[derive(Default)]
struct DummyObserver {
events: Mutex<u64>,
}
impl Observer for DummyObserver {
fn record_event(&self, _event: &ObserverEvent) {
*self.events.lock() += 1;
}
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str {
"dummy"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[test]
fn bridge_forwards_events() {
let inner: Arc<dyn Observer> = Arc::new(DummyObserver::default());
let bridge = ObserverBridge::new(Arc::clone(&inner));
bridge.record_event(&ObserverEvent::HeartbeatTick);
assert_eq!(bridge.name(), "observer-bridge");
}
}

36
src/plugins/hot_reload.rs Normal file
View File

@ -0,0 +1,36 @@
#[derive(Debug, Clone)]
pub struct HotReloadConfig {
pub enabled: bool,
}
impl Default for HotReloadConfig {
fn default() -> Self {
Self { enabled: false }
}
}
#[derive(Debug, Default)]
pub struct HotReloadManager {
config: HotReloadConfig,
}
impl HotReloadManager {
pub fn new(config: HotReloadConfig) -> Self {
Self { config }
}
pub fn enabled(&self) -> bool {
self.config.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hot_reload_disabled_by_default() {
let manager = HotReloadManager::new(HotReloadConfig::default());
assert!(!manager.enabled());
}
}

30
src/plugins/runtime.rs Normal file
View File

@ -0,0 +1,30 @@
use anyhow::Result;
use super::manifest::PluginManifest;
#[derive(Debug, Default)]
pub struct PluginRuntime;
impl PluginRuntime {
pub fn new() -> Self {
Self
}
pub fn load_manifest(&self, manifest: PluginManifest) -> Result<PluginManifest> {
if !manifest.is_valid() {
anyhow::bail!("invalid plugin manifest")
}
Ok(manifest)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_rejects_invalid_manifest() {
let runtime = PluginRuntime::new();
assert!(runtime.load_manifest(PluginManifest::default()).is_err());
}
}

View File

@ -0,0 +1,35 @@
package zeroclaw:hooks@1.0.0;
interface hooks {
enum hook-kind {
gateway-start,
gateway-stop,
session-start,
session-end,
before-compaction,
after-compaction,
tool-result-persist,
}
record hook-event {
kind: hook-kind,
payload-json: string,
}
enum hook-result-kind {
continue,
cancel,
}
record hook-result {
kind: hook-result-kind,
payload-json: option<string>,
reason: option<string>,
}
handle-hook: func(event: hook-event) -> hook-result;
}
world plugin-hooks {
export hooks;
}

View File

@ -0,0 +1,27 @@
package zeroclaw:providers@1.0.0;
interface providers {
record provider-info {
name: string,
}
record chat-request {
model: string,
temperature: float64,
messages-json: string,
tools-json: option<string>,
}
record chat-response {
text: option<string>,
tool-calls-json: string,
usage-json: option<string>,
}
provider-info: func() -> provider-info;
chat: func(request: chat-request) -> chat-response;
}
world plugin-providers {
export providers;
}

View File

@ -0,0 +1,22 @@
package zeroclaw:tools@1.0.0;
interface tools {
record tool-spec {
name: string,
description: string,
parameters-json: string,
}
record tool-exec-result {
success: bool,
output: string,
error: option<string>,
}
list-tools: func() -> list<tool-spec>;
execute-tool: func(name: string, args-json: string) -> tool-exec-result;
}
world plugin-tools {
export tools;
}