Back to Picoclaw

Plugin Tool Injection Example

docs/architecture/hooks/plugin-tool-injection.md

0.2.815.7 KB
Original Source

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
  }
}
FieldDescription
for_llmRequired, LLM will see this content
for_userOptional, sent directly to user
silentWhen true, not sent to user
is_errorWhen true, indicates execution failure
mediaOptional, media file references (images, files, etc.)
response_handledWhen 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.