Files
picoclaw/docs/hooks/plugin-tool-injection.md
T
Harmoon ee29aaa871 Enhance hooks with respond action and comprehensive documentation (#2215)
* feat(hooks): add respond action for tool execution bypass

Add a new HookActionRespond that allows hooks to return tool results directly, skipping actual tool execution. This enables plugin tool injection, caching, and mocking capabilities.

- Add HookActionRespond constant and support in HookManager
- Extend ToolCallHookRequest with HookResult field
- Implement respond action handling in process hooks and agent loop
- Add comprehensive tests for respond and deny_tool actions
- Update documentation with hook actions table and examples

* docs(hooks): add JSON-RPC protocol and plugin tool injection documentation

Add comprehensive documentation for hook JSON-RPC protocol and plugin tool injection capabilities:

- Add "Hook Actions" section to README.zh.md explaining respond action for tool execution bypass
- Create hook-json-protocol.md/.zh.md detailing JSON-RPC 2.0 protocol for all hook methods
- Create plugin-tool-injection.md/.zh.md with complete examples for external tool implementation
- Document how hooks can inject tool definitions and return results via respond action
- Include Python and Go examples for weather query plugin implementation

* feat(agent): emit tool events and feedback for hook results

Add ToolExecStart event emission and tool feedback for hook results to ensure consistent behavior between normal tool execution and hook bypass scenarios. This maintains parity in event tracking and user feedback when tools are executed via hooks.

* style(agent): format whitespace in hook structs and constants

Remove trailing whitespace and standardize spacing in JSON struct tags, constants, and test data for improved code consistency.

* feat(hooks): add media support for plugin tool injection

Extend the hook respond action to support media file handling:
- Add `media` field for returning images and files from hooks
- Add `response_handled` field to control turn completion behavior
- When response_handled=true, media is automatically delivered to user
- When response_handled=false, media is passed to LLM for vision requests

This enables plugins to directly return generated images, downloaded
files, and other media content either to users or for LLM analysis.

* docs(hooks): document security implications of respond action

Add security boundary documentation explaining that the respond action
bypasses ApproveTool checks, allowing hooks to return results for any
tool without approval. Include recommendations for secure hook
implementation and code comments marking the security considerations.

Changes:
- Add "Security Boundaries" section to plugin-tool-injection docs
- Document bypass of approval checks and associated risks
- Provide security recommendations and example code
- Add inline security comments in hooks.go and loop.go

* refactor(agent): improve completeness of tool result cloning and hook processing

Extend cloneToolResult to properly copy ArtifactTags and Messages fields,
ensuring deep copies of all ToolResult data. Consolidate event emission
and user message handling to match the normal tool execution flow.

* fix(agent): align hook respond path with normal tool execution flow

The hook respond code path was missing several critical behaviors that
existed in normal tool execution:

- Add logging for tool calls with arguments preview
- Add is_tool_call metadata to user-facing messages
- Handle attachment delivery failures by setting error state and
  notifying LLM
- Set ResponseHandled=false when using bus for media delivery
- Check for steering messages and graceful interrupts after tool
  execution, skipping remaining tools when appropriate
- Poll for SubTurn results that arrived during tool execution

This ensures consistent behavior between hook-responded tool calls and
normally executed tool calls.

* test(agent): add tests for hook respond media error handling

Add comprehensive tests for the hook respond code path when media
delivery fails. Tests cover error media channel scenarios and verify
proper error state handling.

Also document that AfterTool is not called when using respond action,
as it provides the final answer directly (design decision).
2026-04-08 11:47:02 +08:00

587 lines
16 KiB
Markdown

# Plugin Tool Injection Example
This document demonstrates how to use PicoClaw's hook system to implement external plugin tool injection, allowing LLM to call tools implemented by external hook processes.
---
## Core Principle
Through the hook system's `respond` action, external hooks can:
1. Inject tool **definitions** in `before_llm`, letting LLM know the tool is available
2. Return tool **execution results** directly in `before_tool` using `respond` action, skipping ToolRegistry
This way, external hooks can fully implement plugin tools without registering any tools inside PicoClaw.
---
## Complete Example: Weather Query Plugin
Below is a complete Python hook example implementing a weather query plugin tool.
### 1. Hook Script Implementation
Save as `/tmp/weather_plugin.py`:
```python
#!/usr/bin/env python3
"""Weather query plugin hook example"""
from __future__ import annotations
import json
import sys
import signal
from typing import Any
# Simulated weather data
WEATHER_DATA = {
"Beijing": {"temp": 15, "weather": "Sunny", "humidity": 45},
"Shanghai": {"temp": 18, "weather": "Cloudy", "humidity": 60},
"Guangzhou": {"temp": 25, "weather": "Sunny", "humidity": 70},
"Shenzhen": {"temp": 26, "weather": "Cloudy", "humidity": 75},
}
def get_weather(city: str) -> dict:
"""Get weather data (simulated)"""
data = WEATHER_DATA.get(city)
if data:
return {
"for_llm": f"{city} weather: {data['weather']}, temperature {data['temp']}°C, humidity {data['humidity']}%",
"for_user": "",
"silent": False,
"is_error": False,
}
return {
"for_llm": f"Weather data not found for city {city}",
"for_user": "",
"silent": False,
"is_error": True,
}
def handle_hello(params: dict) -> dict:
return {"ok": True, "name": "weather-plugin"}
def handle_before_llm(params: dict) -> dict:
"""Inject weather query tool definition"""
tools = params.get("tools", [])
# Add weather query tool
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "Query weather information for a specified city",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g.: Beijing, Shanghai, Guangzhou"
}
},
"required": ["city"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
"""Handle tool call, return result directly"""
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
city = args.get("city", "")
result = get_weather(city)
# Use respond action to return result directly, skip ToolRegistry
return {
"action": "respond",
"result": result,
}
# Other tools continue normal flow
return {"action": "continue"}
def handle_request(method: str, params: dict) -> dict:
if method == "hook.hello":
return handle_hello(params)
if method == "hook.before_llm":
return handle_before_llm(params)
if method == "hook.before_tool":
return handle_before_tool(params)
if method == "hook.after_llm":
return {"action": "continue"}
if method == "hook.after_tool":
return {"action": "continue"}
if method == "hook.approve_tool":
return {"approved": True}
raise KeyError(f"method not found: {method}")
def send_response(message_id: int, result: Any | None = None, error: str | None = None) -> None:
payload: dict[str, Any] = {
"jsonrpc": "2.0",
"id": message_id,
}
if error is not None:
payload["error"] = {"code": -32000, "message": error}
else:
payload["result"] = result if result is not None else {}
sys.stdout.write(json.dumps(payload, ensure_ascii=True) + "\n")
sys.stdout.flush()
def main() -> int:
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
method = message.get("method")
message_id = message.get("id", 0)
params = message.get("params") or {}
if not message_id:
continue
try:
result = handle_request(str(method or ""), params)
send_response(int(message_id), result=result)
except KeyError as exc:
send_response(int(message_id), error=str(exc))
except Exception as exc:
send_response(int(message_id), error=f"unexpected error: {exc}")
return 0
if __name__ == "__main__":
signal.signal(signal.SIGINT, lambda *_: raise SystemExit(0))
signal.signal(signal.SIGTERM, lambda *_: raise SystemExit(0))
raise SystemExit(main())
```
### 2. Configure PicoClaw
Add hook configuration in the config file:
```json
{
"hooks": {
"enabled": true,
"processes": {
"weather_plugin": {
"enabled": true,
"priority": 100,
"transport": "stdio",
"command": ["python3", "/tmp/weather_plugin.py"],
"intercept": ["before_llm", "before_tool"]
}
}
}
}
```
### 3. Test Results
When user asks "What's the weather in Beijing today?":
1. PicoClaw sends `hook.before_llm`, hook injects `get_weather` tool definition
2. LLM sees tool definition, decides to call `get_weather(city="Beijing")`
3. PicoClaw sends `hook.before_tool`, hook uses `respond` action to return weather data
4. LLM receives result, replies to user "Beijing is sunny today, temperature 15°C"
---
## Flow Diagram
```
User: "What's the weather in Beijing today?"
PicoClaw
hook.before_llm
↓ (inject get_weather tool definition)
LLM request
LLM decides to call get_weather(city="Beijing")
hook.before_tool
↓ (respond action returns weather data)
Return result directly to LLM
↓ (skip ToolRegistry)
LLM replies: "Beijing is sunny today, temperature 15°C"
```
---
## Key Points
### `before_llm` Inject Tool Definition
Tool definition follows OpenAI function calling format:
```json
{
"type": "function",
"function": {
"name": "tool_name",
"description": "tool description",
"parameters": {
"type": "object",
"properties": {
"param_name": {
"type": "string",
"description": "parameter description"
}
},
"required": ["list of required parameters"]
}
}
}
```
### `before_tool` Use respond Action
`respond` action response format:
```json
{
"action": "respond",
"result": {
"for_llm": "Content returned to LLM",
"for_user": "Optional, content sent to user",
"silent": false,
"is_error": false,
"media": ["Optional, media reference list"],
"response_handled": false
}
}
```
| Field | Description |
|-------|-------------|
| `for_llm` | Required, LLM will see this content |
| `for_user` | Optional, sent directly to user |
| `silent` | When true, not sent to user |
| `is_error` | When true, indicates execution failure |
| `media` | Optional, media file references (images, files, etc.) |
| `response_handled` | When true, indicates user request is handled, turn will end |
---
## Media File Handling
The `respond` action supports returning media files (images, files, etc.). There are two processing modes:
### 1. Automatic Delivery (`response_handled=true`)
When `response_handled=true`, media files are automatically sent to the user and the turn ends:
```json
{
"action": "respond",
"result": {
"for_llm": "Image sent to user",
"for_user": "",
"media": ["media://abc123"],
"response_handled": true
}
}
```
Use cases:
- Image generation plugin directly returning results
- File download plugin sending files to user
### 2. LLM Visible (`response_handled=false`)
When `response_handled=false`, media references are passed to the LLM, which can see the content in the next request:
```json
{
"action": "respond",
"result": {
"for_llm": "Image loaded, path: /tmp/image.png [file:/tmp/image.png]",
"media": ["media://abc123"]
}
}
```
After seeing the content, the LLM can decide:
- Use `send_file` tool to send to user
- Analyze image content and reply to user
- Other processing approaches
### Media Reference Format
Media references use the `media://` protocol:
```
media://<store-id>
```
These references are managed by PicoClaw's MediaStore and can be:
- Sent to user via channel
- Converted to base64 in LLM vision requests
### Alternative: Use Existing Tools
If the plugin generates files, you can return the file path and let the LLM call `send_file` or similar tools:
```json
{
"action": "respond",
"result": {
"for_llm": "Image generated, saved at /tmp/generated_image.png. Use send_file tool to send to user.",
"for_user": "",
"silent": false
}
}
```
This approach:
- More decoupled, LLM decides when to send
- Leverages existing tool mechanisms
- Supports batch sending, delayed sending, etc.
---
## Multi-Tool Injection Example
Multiple tools can be injected simultaneously:
```python
def handle_before_llm(params: dict) -> dict:
tools = params.get("tools", [])
# Tool 1: Weather query
tools.append({
"type": "function",
"function": {
"name": "get_weather",
"description": "Query city weather",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
})
# Tool 2: Calculator
tools.append({
"type": "function",
"function": {
"name": "calculate",
"description": "Perform mathematical calculations",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Mathematical expression"}
},
"required": ["expression"]
}
}
})
return {
"action": "modify",
"request": {
"model": params.get("model"),
"messages": params.get("messages", []),
"tools": tools,
"options": params.get("options", {}),
}
}
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
if tool == "get_weather":
return {
"action": "respond",
"result": get_weather(args.get("city", "")),
}
if tool == "calculate":
# Simple calculation example
try:
expr = args.get("expression", "")
result = eval(expr) # Note: needs security handling in actual use
return {
"action": "respond",
"result": {
"for_llm": f"Calculation result: {result}",
"silent": False,
"is_error": False,
},
}
except Exception as e:
return {
"action": "respond",
"result": {
"for_llm": f"Calculation error: {e}",
"silent": False,
"is_error": True,
},
}
return {"action": "continue"}
```
---
## Coexistence with Built-in Tools
Injected plugin tools coexist with PicoClaw built-in tools:
- Built-in tools (like `bash`, `read_file`) execute normally through ToolRegistry
- Plugin tools return results through hook's `respond` action
- `handle_before_tool` only handles plugin tools, other tools return `continue`
---
## Go In-Process Hook Example
If you need to implement plugin tool injection in Go code:
```go
package myhooks
import (
"context"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/tools"
)
type WeatherPluginHook struct{}
func (h *WeatherPluginHook) BeforeLLM(
ctx context.Context,
req *agent.LLMHookRequest,
) (*agent.LLMHookRequest, agent.HookDecision, error) {
// Inject tool definition
req.Tools = append(req.Tools, agent.ToolDefinition{
Type: "function",
Function: agent.FunctionDefinition{
Name: "get_weather",
Description: "Query city weather",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{
"type": "string",
"description": "City name",
},
},
"required": []string{"city"},
},
},
})
return req, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func (h *WeatherPluginHook) BeforeTool(
ctx context.Context,
call *agent.ToolCallHookRequest,
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
if call.Tool == "get_weather" {
city := call.Arguments["city"].(string)
// Set HookResult, use respond action
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: getWeatherData(city),
Silent: false,
IsError: false,
}
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
}
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
}
func getWeatherData(city string) string {
// Implement weather query logic
return fmt.Sprintf("%s weather: Sunny, temperature 20°C", city)
}
```
---
## Summary
Through the hook system's `respond` action, external processes can:
1. **Inject tool definitions**: Let LLM know new tools are available
2. **Provide tool implementation**: Return execution results directly, no need to register in ToolRegistry
3. **Coexist with built-in tools**: Does not affect normal operation of PicoClaw's original tools
This provides a flexible and elegant solution for plugin development.
---
## Security Boundaries
### Bypassing Approval Checks
**Important**: The `respond` action bypasses `ApproveTool` approval checks.
This means:
- A `before_tool` hook can return `respond` for **any tool name**, including sensitive tools (like `bash`)
- The tool won't go through the approval process, directly returning the hook-provided result
- This is designed for plugin tools but introduces security risks
### Security Recommendations
1. **Review hook configuration**: Ensure only trusted hook processes are enabled
2. **Limit hook scope**: Add your own security checks in hook implementation
3. **Use `deny_tool` for rejection**: Use `deny_tool` action instead of `respond` with error for denying execution
### Example: Hook-Internal Security Check
```python
def handle_before_tool(params: dict) -> dict:
tool = params.get("tool", "")
args = params.get("arguments", {})
# Security check: only handle plugin tools
if tool in ["get_weather", "calculate"]:
return {
"action": "respond",
"result": execute_plugin_tool(tool, args),
}
# Other tools continue normal flow (will go through approval)
return {"action": "continue"}
```
This ensures the hook only affects plugin tools, not system tool approval flow.