feat(plugins): scaffold wasm runtime and wire core hook lifecycle
This commit is contained in:
parent
c3dbd9a7a7
commit
1d6afe792b
152
docs/plans/2026-02-22-wasm-plugin-runtime-design.md
Normal file
152
docs/plans/2026-02-22-wasm-plugin-runtime-design.md
Normal 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.
|
||||
379
docs/plans/2026-02-22-wasm-plugin-runtime.md
Normal file
379
docs/plans/2026-02-22-wasm-plugin-runtime.md
Normal 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.
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
37
src/hooks/builtin/boot_script.rs
Normal file
37
src/hooks/builtin/boot_script.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
39
src/hooks/builtin/session_memory.rs
Normal file
39
src/hooks/builtin/session_memory.rs
Normal 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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
1
src/plugins/bridge/mod.rs
Normal file
1
src/plugins/bridge/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod observer;
|
||||
71
src/plugins/bridge/observer.rs
Normal file
71
src/plugins/bridge/observer.rs
Normal 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
36
src/plugins/hot_reload.rs
Normal 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
30
src/plugins/runtime.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
wit/zeroclaw/hooks/v1/hooks.wit
Normal file
35
wit/zeroclaw/hooks/v1/hooks.wit
Normal 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;
|
||||
}
|
||||
27
wit/zeroclaw/providers/v1/providers.wit
Normal file
27
wit/zeroclaw/providers/v1/providers.wit
Normal 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;
|
||||
}
|
||||
22
wit/zeroclaw/tools/v1/tools.wit
Normal file
22
wit/zeroclaw/tools/v1/tools.wit
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user