mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(docs): reorganize docs by type and locale
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user