Back to Picoclaw

插件工具注入示例

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

0.2.815.2 KB
Original Source

插件工具注入示例

本文档展示如何利用 PicoClaw 的 hook 系统实现外部插件工具注入,让 LLM 能调用由外部 hook 进程实现的工具。


核心原理

通过 hook 系统的 respond action,外部 hook 可以:

  1. before_llm 中注入工具定义,让 LLM 知道有这个工具可用
  2. before_tool 中使用 respond action 直接返回工具执行结果,跳过 ToolRegistry

这样,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具。


完整示例:天气查询插件

下面是一个完整的 Python hook 示例,实现一个天气查询插件工具。

1. Hook 脚本实现

保存为 /tmp/weather_plugin.py

python
#!/usr/bin/env python3
"""天气查询插件 hook 示例"""
from __future__ import annotations

import json
import sys
import signal
from typing import Any

# 模拟天气数据
WEATHER_DATA = {
    "北京": {"temp": 15, "weather": "晴", "humidity": 45},
    "上海": {"temp": 18, "weather": "多云", "humidity": 60},
    "广州": {"temp": 25, "weather": "晴", "humidity": 70},
    "深圳": {"temp": 26, "weather": "多云", "humidity": 75},
}


def get_weather(city: str) -> dict:
    """获取天气数据(模拟)"""
    data = WEATHER_DATA.get(city)
    if data:
        return {
            "for_llm": f"{city}天气:{data['weather']},温度{data['temp']}°C,湿度{data['humidity']}%",
            "for_user": "",
            "silent": False,
            "is_error": False,
        }
    return {
        "for_llm": f"未找到城市 {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:
    """注入天气查询工具定义"""
    tools = params.get("tools", [])
    
    # 添加天气查询工具
    tools.append({
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询指定城市的天气信息",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称,如:北京、上海、广州"
                    }
                },
                "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:
    """处理工具调用,直接返回结果"""
    tool = params.get("tool", "")
    args = params.get("arguments", {})
    
    if tool == "get_weather":
        city = args.get("city", "")
        result = get_weather(city)
        
        # 使用 respond action 直接返回结果,跳过 ToolRegistry
        return {
            "action": "respond",
            "result": result,
        }
    
    # 其他工具继续正常流程
    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. 配置 PicoClaw

在配置文件中添加 hook 配置:

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. 测试效果

当用户问"北京今天天气怎么样?"时:

  1. PicoClaw 发送 hook.before_llm,hook 注入 get_weather 工具定义
  2. LLM 看到工具定义,决定调用 get_weather(city="北京")
  3. PicoClaw 发送 hook.before_tool,hook 使用 respond action 返回天气数据
  4. LLM 收到结果,回复用户"北京今天晴天,温度15°C"

流程图解

用户: "北京今天天气怎么样?"
        ↓
    PicoClaw
        ↓
    hook.before_llm
        ↓ (注入 get_weather 工具定义)
    LLM 请求
        ↓
    LLM 决定调用 get_weather(city="北京")
        ↓
    hook.before_tool
        ↓ (respond action 返回天气数据)
    直接返回结果给 LLM
        ↓ (跳过 ToolRegistry)
    LLM 回复: "北京今天晴天,温度15°C"

关键点说明

before_llm 注入工具定义

工具定义遵循 OpenAI function calling 格式:

json
{
  "type": "function",
  "function": {
    "name": "工具名称",
    "description": "工具描述",
    "parameters": {
      "type": "object",
      "properties": {
        "参数名": {
          "type": "string",
          "description": "参数描述"
        }
      },
      "required": ["必需参数列表"]
    }
  }
}

before_tool 使用 respond action

respond action 的响应格式:

json
{
  "action": "respond",
  "result": {
    "for_llm": "返回给 LLM 的内容",
    "for_user": "可选,发送给用户的内容",
    "silent": false,
    "is_error": false,
    "media": ["可选,媒体引用列表"],
    "response_handled": false
  }
}
字段说明
for_llm必须,LLM 会看到这个内容
for_user可选,直接发送给用户
silent为 true 时不发送给用户
is_error为 true 时表示执行失败
media可选,媒体文件引用列表(如图片、文件)
response_handled为 true 时表示已处理用户请求,轮次将结束

媒体文件处理

respond action 支持返回媒体文件(图片、文件等)。有两种处理方式:

1. 自动发送(response_handled=true

response_handled=true 时,媒体文件会自动发送给用户,轮次结束:

json
{
  "action": "respond",
  "result": {
    "for_llm": "图片已发送给用户",
    "for_user": "",
    "media": ["media://abc123"],
    "response_handled": true
  }
}

适用场景:

  • 图像生成插件直接返回结果
  • 文件下载插件发送文件给用户

2. LLM 可见(response_handled=false

response_handled=false 时,媒体引用会传递给 LLM,LLM 可以在下一轮请求中看到内容:

json
{
  "action": "respond",
  "result": {
    "for_llm": "图片已加载,路径:/tmp/image.png [file:/tmp/image.png]",
    "media": ["media://abc123"]
  }
}

LLM 看到内容后,可以自主决定:

  • 使用 send_file 工具发送给用户
  • 分析图片内容并回复用户
  • 其他处理方式

媒体引用格式

媒体引用使用 media:// 协议:

media://<store-id>

这些引用由 PicoClaw 的 MediaStore 管理,可以:

  • 通过 channel 发送给用户
  • 在 LLM vision 请求中转换为 base64

替代方案:使用现有工具

如果插件生成文件,可以返回文件路径让 LLM 调用 send_file 等工具:

json
{
  "action": "respond",
  "result": {
    "for_llm": "图片已生成,保存在 /tmp/generated_image.png。使用 send_file 工具发送给用户。",
    "for_user": "",
    "silent": false
  }
}

这种方式:

  • 更解耦,LLM 自主决策发送时机
  • 利用现有工具机制
  • 支持批量发送、延迟发送等场景

多工具注入示例

可以同时注入多个工具:

python
def handle_before_llm(params: dict) -> dict:
    tools = params.get("tools", [])
    
    # 工具1:天气查询
    tools.append({
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询城市天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称"}
                },
                "required": ["city"]
            }
        }
    })
    
    # 工具2:计算器
    tools.append({
        "type": "function",
        "function": {
            "name": "calculate",
            "description": "执行数学计算",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {"type": "string", "description": "数学表达式"}
                },
                "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":
        # 简单计算示例
        try:
            expr = args.get("expression", "")
            result = eval(expr)  # 注意:实际使用时需要安全处理
            return {
                "action": "respond",
                "result": {
                    "for_llm": f"计算结果: {result}",
                    "silent": False,
                    "is_error": False,
                },
            }
        except Exception as e:
            return {
                "action": "respond",
                "result": {
                    "for_llm": f"计算错误: {e}",
                    "silent": False,
                    "is_error": True,
                },
            }
    
    return {"action": "continue"}

与内置工具共存

注入的插件工具与 PicoClaw 内置工具共存:

  • 内置工具(如 bashread_file)正常通过 ToolRegistry 执行
  • 插件工具通过 hook 的 respond action 返回结果
  • handle_before_tool 中只处理插件工具,其他工具返回 continue

Go 进程内 Hook 示例

如果需要在 Go 代码中实现插件工具注入:

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) {
    // 注入工具定义
    req.Tools = append(req.Tools, agent.ToolDefinition{
        Type: "function",
        Function: agent.FunctionDefinition{
            Name:        "get_weather",
            Description: "查询城市天气",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "city": map[string]any{
                        "type":        "string",
                        "description": "城市名称",
                    },
                },
                "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)
        
        // 设置 HookResult,使用 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 {
    // 实现天气查询逻辑
    return fmt.Sprintf("%s天气:晴,温度20°C", city)
}

总结

通过 hook 系统的 respond action,外部进程可以:

  1. 注入工具定义:让 LLM 知道有新工具可用
  2. 提供工具实现:直接返回执行结果,无需注册到 ToolRegistry
  3. 与内置工具共存:不影响 PicoClaw 原有工具的正常运行

这为插件开发提供了灵活、优雅的解决方案。


安全边界说明

绕过审批检查

重要respond action 会绕过 ApproveTool 审批检查。

这意味着:

  • before_tool hook 可以为任何工具名称返回 respond,包括敏感工具(如 bash
  • 工具不会经过审批流程,直接返回 hook 提供的结果
  • 这是为了支持插件工具而设计,但也带来了安全风险

安全建议

  1. 审查 hook 配置:确保只有可信的 hook 进程被启用
  2. 限制 hook 权限:在 hook 实现中添加自己的安全检查
  3. 优先使用 deny_tool:对于拒绝执行,使用 deny_tool action 而非 respond 返回错误

示例:hook 内置安全检查

python
def handle_before_tool(params: dict) -> dict:
    tool = params.get("tool", "")
    args = params.get("arguments", {})
    
    # 安全检查:只处理插件工具
    if tool in ["get_weather", "calculate"]:
        return {
            "action": "respond",
            "result": execute_plugin_tool(tool, args),
        }
    
    # 其他工具继续正常流程(会经过审批)
    return {"action": "continue"}

这样可以确保 hook 只影响插件工具,不影响系统工具的审批流程。