# 插件工具注入示例

本文档展示如何利用 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 内置工具共存：

- 内置工具（如 `bash`、`read_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 只影响插件工具，不影响系统工具的审批流程。