mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
ee29aaa871
* 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).
587 lines
16 KiB
Markdown
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. |