# 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:// ``` 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.