Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f73fb27
feat(hooks): add lightweight lifecycle hook system
gh-xj Feb 19, 2026
f26d207
merge: resolve upstream/main conflicts for hook-system
gh-xj Feb 22, 2026
873ebd1
docs(hooks): add plugin-style lifecycle hook examples
gh-xj Feb 22, 2026
8d4cbd7
feat(plugin): add phase-1 compile-time plugin contract
gh-xj Feb 22, 2026
676f50f
docs(plugin): document plugin model and phased roadmap
gh-xj Feb 22, 2026
50d6562
fix(hooks): address copilot race and diagnostics feedback
gh-xj Feb 22, 2026
8aa0a30
docs(hooks): clarify BeforeToolCallEvent args non-nil contract
gh-xj Feb 22, 2026
274f0f7
feat(plugin): add policy demo plugin with runtime enforcement cases
gh-xj Feb 22, 2026
cd0ea88
docs(plugin): prefer subprocess rpc over go .so plugins
gh-xj Feb 23, 2026
b213613
feat(plugin): enforce api version compatibility at registration
gh-xj Feb 23, 2026
96097ff
fix(agent): make hook setup recoverable and preserve request context
gh-xj Feb 23, 2026
2c144ff
chore: address remaining copilot nits and clarify docs
gh-xj Feb 23, 2026
cad5804
docs(hooks): update SetHooks examples for error return
gh-xj Feb 23, 2026
7be229d
docs: move plugin roadmap out of docs/design and drop plan draft
gh-xj Feb 23, 2026
76efd6c
merge: resolve upstream/main conflicts and harden hook/plugin edge cases
gh-xj Feb 26, 2026
e3094ec
fix(lint): address golines and vet issues on hook/plugin changes
gh-xj Feb 26, 2026
23f909f
fix(hooks): restore direct message callback when hooks disabled
gh-xj Feb 26, 2026
dae1228
fix(hooks): isolate void-hook events per handler
gh-xj Feb 26, 2026
116904c
fix(hooks): deep-clone typed payloads and add regressions
gh-xj Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,18 @@ PicoClaw routes providers by protocol family:

This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).

### Lifecycle Hooks (Plugin-style Extensions)

PicoClaw provides typed lifecycle hooks for observability, outbound filtering, and tool guardrails.

- Register hooks in Go at startup with `hooks.NewHookRegistry()`.
- Attach once via `agentLoop.SetHooks(registry)` before `Run()` and handle setup errors.
- If hooks are not set, default behavior is unchanged.

See runnable examples: [docs/hooks-plugin-examples.md](docs/hooks-plugin-examples.md)
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing blank line between documentation links. For better readability and consistency with Markdown best practices, add a blank line between these two documentation references.

Suggested change
See runnable examples: [docs/hooks-plugin-examples.md](docs/hooks-plugin-examples.md)
See runnable examples: [docs/hooks-plugin-examples.md](docs/hooks-plugin-examples.md)

Copilot uses AI. Check for mistakes.

Roadmap for plugin system evolution: [docs/plugin-system-roadmap.md](docs/plugin-system-roadmap.md)

<details>
<summary><b>Zhipu</b></summary>

Expand Down
138 changes: 138 additions & 0 deletions docs/hooks-plugin-examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Lifecycle Hooks: Plugin-Style Examples

This guide shows how to extend PicoClaw behavior with `pkg/hooks` without modifying core agent logic.

For future direction (beyond current hooks foundation), see [Plugin System Roadmap](plugin-system-roadmap.md).

Current model:
- "Plugin-style" means registering Go handlers at startup.
- Hooks are in-process (no dynamic `.so` loading).
- If no hooks are registered, the runtime follows the normal zero-cost path.

## How Plugin Works

PicoClaw's plugin model is a startup-time hook registry:

1. Build a registry (`hooks.NewHookRegistry()`).
2. Register one or more handlers per lifecycle hook with priority.
3. Attach once with `agentLoop.SetHooks(registry)` before `agentLoop.Run(...)` (check error).
4. Agent loop triggers hook handlers at specific lifecycle points.

Execution semantics:

- Observe-only hooks (`message_received`, `after_tool_call`, `llm_input`, `llm_output`, `session_start`, `session_end`)
- run concurrently
- cannot block core behavior
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation claims observe-only hooks “cannot block core behavior”, but the current implementation of observe-only hooks (triggerVoid) waits for all handlers to complete via a WaitGroup. This means slow/blocked handlers will delay the agent loop. Update the wording to reflect that observe-only hooks cannot cancel/modify the operation but can still add latency (or change triggerVoid to be fire-and-forget if that’s the intended contract).

Suggested change
- cannot block core behavior
- cannot cancel or modify core behavior (observe-only only)
- may still add latency because handlers are awaited by the agent loop

Copilot uses AI. Check for mistakes.
- Modifying hooks (`message_sending`, `before_tool_call`)
- run sequentially by priority (lower number first)
- may mutate event data
- may cancel operation via `Cancel=true`

Safety model:

- Panic in one handler is recovered and logged.
- Handler errors are logged; pipeline continues unless canceled by event flag.
- With no registered hooks, agent loop behavior is unchanged.

Lifecycle map:

```text
Inbound message
-> message_received
-> session_start
-> llm_input
-> llm_output
-> before_tool_call (cancelable)
-> tool execute
-> after_tool_call
-> message_sending (cancelable)
-> outbound publish
-> session_end
```

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lifecycle map shows a linear flow, but in reality, llm_input, llm_output, before_tool_call, and after_tool_call can occur multiple times in an iteration loop (up to MaxToolIterations). Consider adding a note about iteration/looping behavior to avoid confusion for plugin developers who need to understand when their hooks will fire.

Suggested change
Note: The map above is shown as a single linear pass for readability. In practice, the
agent loop may iterate up to `MaxToolIterations`, and the following hooks can fire
multiple times within a single overall lifecycle:
`llm_input`, `llm_output`, `before_tool_call`, and `after_tool_call`.

Copilot uses AI. Check for mistakes.
Note: the map above is shown as a single pass for readability. In practice, the
agent loop may iterate up to `MaxToolIterations`, and `llm_input`, `llm_output`,
`before_tool_call`, and `after_tool_call` can fire multiple times.

## Available Hooks

| Hook | Type | Typical use |
|---|---|---|
| `message_received` | observe-only | inbound telemetry |
| `message_sending` | modifying + cancel | content filtering, safety policy |
| `before_tool_call` | modifying + cancel | tool allow/deny, arg rewriting |
| `after_tool_call` | observe-only | latency/error metrics |
| `llm_input` | observe-only | prompt size monitoring |
| `llm_output` | observe-only | response/tool-call telemetry |
| `session_start` | observe-only | session audit |
| `session_end` | observe-only | session cleanup metrics |

## Quick Start

```go
package main

import (
"context"
"strings"

"github.com/sipeed/picoclaw/pkg/hooks"
)

func buildHooks() *hooks.HookRegistry {
reg := hooks.NewHookRegistry()

// 1) Guardrail: block shell tool globally.
reg.OnBeforeToolCall("block-shell", 100, func(_ context.Context, e *hooks.BeforeToolCallEvent) error {
if e.ToolName == "shell" {
e.Cancel = true
e.CancelReason = "shell tool is disabled by local policy"
}
return nil
})

// 2) Outbound filter: redact obvious API key patterns.
reg.OnMessageSending("redact-secrets", 50, func(_ context.Context, e *hooks.MessageSendingEvent) error {
e.Content = strings.ReplaceAll(e.Content, "sk-", "[redacted]-")
return nil
})

// 3) Telemetry: record tool latency or errors.
reg.OnAfterToolCall("tool-telemetry", 0, func(_ context.Context, e *hooks.AfterToolCallEvent) error {
// Send to metrics backend / logs as needed.
_ = e.ToolName
_ = e.Duration
_ = e.Result
return nil
})

return reg
}
```

Attach once during startup:

```go
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
if err := agentLoop.SetHooks(buildHooks()); err != nil {
panic(err) // replace with your startup error handling
}
```

## Priority and Cancellation

- Lower `priority` runs first.
- `message_sending` and `before_tool_call` are sequential and can cancel.
- Other hooks are observe-only and run concurrently.

Recommended ordering:
- `0-49`: telemetry and logging
- `50-89`: transforms (redaction, normalization)
- `90+`: hard guardrails (block/cancel)

## Safety Notes

- Hook panics are recovered internally; one bad hook does not crash the loop.
- Hook errors are logged and execution continues unless `Cancel` is set.
- Observe-only hooks (`message_received`, `after_tool_call`, `llm_input`, `llm_output`, `session_start`, `session_end`) must treat events as read-only.
- Keep hook handlers fast and non-blocking to avoid latency impact.
108 changes: 108 additions & 0 deletions docs/plugin-system-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Plugin System Roadmap

This document defines how PicoClaw evolves from hook-based extension points to a fuller plugin system in low-risk phases.

## Current Status (Phase 0: Foundation)

Implemented in current hooks PR:

- Typed lifecycle hooks (`pkg/hooks`)
- Priority-based handler ordering
- Cancellation support for modifying hooks
- Panic recovery and error isolation
- Agent-loop integration via `agentLoop.SetHooks(...)`

Compatibility:

- If no hooks are registered, runtime behavior is unchanged.
- No config migration is required.

## Non-Goals in Phase 0

- No dynamic runtime plugin loading
- No remote plugin marketplace/distribution
- No plugin sandboxing model
- No stable external plugin ABI yet
- No Go `.so` plugin loading as default direction

## Phase Plan

## Phase 1: Static Plugin Contract (Compile-time) — Implemented

Goal: define a minimal public plugin contract for Go modules.

Implemented:

- Add `pkg/plugin` with a small interface:
- `Name() string`
- `APIVersion() string`
- `Register(*hooks.HookRegistry) error`
- Register plugins at startup in code.
- Add compatibility metadata (`plugin.APIVersion`) and registration-time checks.

Exit criteria (met):

- Example plugin module builds against the contract.
- Startup validation logs loaded plugins and registration errors clearly.

## Phase 2: Config-driven Enable/Disable

Goal: operational control without code changes.

Proposed:

- Add plugin list/config in `config.json`:
- enabled/disabled flags
- optional plugin-specific settings
- Deterministic load order and conflict resolution rules.

Exit criteria:

- Users can toggle plugins without rebuilding.
- Clear startup diagnostics for invalid plugin config.

## Phase 3: Developer Experience

Goal: make third-party plugin development straightforward.

Proposed:

- Provide `examples/plugins/*` reference implementations.
- Publish plugin authoring guide (lifecycle map, best practices, safety constraints).
- Add plugin-focused test harness pattern for hook behavior verification.

Exit criteria:

- New plugin can be built from template with minimal boilerplate.
- CI examples demonstrate expected behavior and regression checks.

## Phase 4: Optional Dynamic Loading (Separate RFC)

Goal: support runtime-loaded plugins only if security and operability are acceptable.

Preferred direction:

- Runtime plugins run as subprocesses.
- Host and plugin communicate via RPC/gRPC.
- Host manages lifecycle (spawn/health/timeout/restart), not in-process dynamic loading.

Why this direction:

- Go native `.so` plugin loading has strict toolchain/ABI coupling with host binary.
- Subprocess RPC model reduces coupling and improves fault isolation.
- Process boundary provides a cleaner place for permissions and sandbox controls.

Preconditions:

- Threat model approved
- Signature/trust model defined
- Sandboxing and permission boundaries defined
- Rollback and safe-disable behavior validated
- Versioned RPC handshake and capability negotiation defined
- Process supervision policy defined (timeouts, retries, crash loop backoff)

Until then, compile-time registration remains the recommended model.

## Maintainer Review Notes

The current hooks PR should be reviewed as Phase 0+1 only. It intentionally establishes extension points while avoiding high-risk runtime plugin mechanics.
Loading