mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Enhance hooks with respond action and comprehensive documentation (#2215)
* 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).
This commit is contained in:
@@ -28,6 +28,69 @@ The currently exposed synchronous hook points are:
|
||||
|
||||
Everything else is exposed as read-only events.
|
||||
|
||||
## Hook Actions
|
||||
|
||||
Hooks can return different actions to control the flow:
|
||||
|
||||
| Action | Applicable Stages | Effect |
|
||||
| --- | --- | --- |
|
||||
| `continue` | All interceptors | Pass through without modification |
|
||||
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | Modify request/response and continue |
|
||||
| `respond` | `before_tool` | Return a tool result directly, skip actual tool execution |
|
||||
| `deny_tool` | `before_tool` | Deny tool execution, return error message |
|
||||
| `abort_turn` | All interceptors | Abort the current turn |
|
||||
| `hard_abort` | All interceptors | Force stop the entire agent loop |
|
||||
|
||||
### The `respond` Action
|
||||
|
||||
The `respond` action is special: it allows a `before_tool` hook to provide the tool result directly, skipping the actual tool execution. This is useful for:
|
||||
|
||||
1. **Plugin tool injection**: External hooks can implement tools without registering them in the tool registry
|
||||
2. **Tool result caching**: Return cached results for repeated tool calls
|
||||
3. **Tool mocking**: Return mock results for testing purposes
|
||||
|
||||
When a hook returns `respond` with a `HookResult`, the agent loop:
|
||||
1. Skips the actual tool execution
|
||||
2. Uses the provided result as if the tool had executed
|
||||
3. Continues the turn normally with the result
|
||||
|
||||
Example (Go in-process hook):
|
||||
|
||||
```go
|
||||
func (h *MyHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "my_plugin_tool" {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: "Plugin tool executed successfully",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Example (Python process hook):
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
if tool == "my_plugin_tool":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
`HookManager` sorts hooks like this:
|
||||
|
||||
@@ -28,6 +28,69 @@
|
||||
|
||||
其余 lifecycle 通过事件形式只读暴露。
|
||||
|
||||
## Hook Actions
|
||||
|
||||
Hook 可以返回不同的 action 来控制流程:
|
||||
|
||||
| Action | 适用阶段 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| `continue` | 所有拦截型 | 放行,不做修改 |
|
||||
| `modify` | `before_llm`, `after_llm`, `before_tool`, `after_tool` | 改写请求/响应后放行 |
|
||||
| `respond` | `before_tool` | 直接返回工具结果,跳过实际工具执行 |
|
||||
| `deny_tool` | `before_tool` | 拒绝工具执行,返回错误信息 |
|
||||
| `abort_turn` | 所有拦截型 | 中止当前 turn |
|
||||
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
|
||||
|
||||
### `respond` Action
|
||||
|
||||
`respond` action 是特殊的:它允许 `before_tool` hook 直接提供工具结果,跳过实际工具执行。适用于:
|
||||
|
||||
1. **插件工具注入**:外部 hook 可以实现工具,无需在 ToolRegistry 注册
|
||||
2. **工具结果缓存**:对重复调用返回缓存结果
|
||||
3. **工具模拟**:测试时返回模拟结果
|
||||
|
||||
当 hook 返回 `respond` 并携带 `HookResult` 时,agent loop 会:
|
||||
1. 跳过实际工具执行
|
||||
2. 使用提供的结果作为工具执行结果
|
||||
3. 正常继续 turn 流程
|
||||
|
||||
示例(Go 进程内 hook):
|
||||
|
||||
```go
|
||||
func (h *MyHook) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *agent.ToolCallHookRequest,
|
||||
) (*agent.ToolCallHookRequest, agent.HookDecision, error) {
|
||||
if call.Tool == "my_plugin_tool" {
|
||||
next := call.Clone()
|
||||
next.HookResult = &tools.ToolResult{
|
||||
ForLLM: "Plugin tool executed successfully",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
}
|
||||
return next, agent.HookDecision{Action: agent.HookActionRespond}, nil
|
||||
}
|
||||
return call, agent.HookDecision{Action: agent.HookActionContinue}, nil
|
||||
}
|
||||
```
|
||||
|
||||
示例(Python process hook):
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
if tool == "my_plugin_tool":
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
## 执行顺序
|
||||
|
||||
HookManager 的排序规则是:
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
# Hook JSON-RPC Protocol Details
|
||||
|
||||
All hooks use `JSON-RPC 2.0` format, with one JSON message per line, transmitted via stdio.
|
||||
|
||||
---
|
||||
|
||||
## Basic Protocol Structure
|
||||
|
||||
### Request (PicoClaw → Hook)
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
|
||||
```
|
||||
|
||||
### Response (Hook → PicoClaw)
|
||||
|
||||
Success:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{...}}
|
||||
```
|
||||
|
||||
Error:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"error message"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `hook.hello` (Handshake)
|
||||
|
||||
Handshake must be completed at startup, otherwise the hook process will be terminated.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "hook.hello",
|
||||
"params": {
|
||||
"name": "py_review_gate",
|
||||
"version": 1,
|
||||
"modes": ["observe", "tool", "approve"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | hook name (from configuration) |
|
||||
| `version` | protocol version, currently `1` |
|
||||
| `modes` | capability modes supported by the hook |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"name": "python-review-gate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `hook.before_llm`
|
||||
|
||||
Triggered before sending request to LLM. Can be used to inject tools.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "hook.before_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"ParentTurnID": "",
|
||||
"SessionKey": "session-1",
|
||||
"Iteration": 0,
|
||||
"TracePath": "runTurn",
|
||||
"Source": "turn.llm.request"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo text",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1",
|
||||
"graceful_terminal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `meta` | event metadata for tracing |
|
||||
| `model` | requested model name |
|
||||
| `messages` | conversation history |
|
||||
| `tools` | list of available tool definitions |
|
||||
| `options` | LLM parameters (temperature, max_tokens, etc.) |
|
||||
| `channel` | request source channel |
|
||||
| `chat_id` | session ID |
|
||||
|
||||
### Response (Tool Injection Example)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo",
|
||||
"parameters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "Plugin injected tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `action` | decision action (see table below) |
|
||||
| `request` | modified request object |
|
||||
|
||||
---
|
||||
|
||||
## 3. `hook.after_llm`
|
||||
|
||||
Triggered after receiving LLM response. Can modify response content.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "hook.after_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"response": {
|
||||
"role": "assistant",
|
||||
"content": "Hi!",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"arguments": "{\"text\":\"hi\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `hook.before_tool`
|
||||
|
||||
Triggered before tool execution. Can modify tool name and arguments, deny execution, or return result directly.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "hook.before_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `tool` | tool name |
|
||||
| `arguments` | tool arguments |
|
||||
|
||||
### Response (Modify Arguments)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"call": {
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "modified hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Deny Execution)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "deny_tool",
|
||||
"reason": "Invalid arguments"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Return Result Directly - respond)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "respond",
|
||||
"call": {
|
||||
"tool": "my_plugin_tool",
|
||||
"arguments": {
|
||||
"query": "hello"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `respond` action allows hooks to return tool results directly, skipping actual tool execution. Use cases:
|
||||
1. **Plugin tool injection**: External hooks can implement tools without registering in ToolRegistry
|
||||
2. **Tool result caching**: Return cached results for repeated calls
|
||||
3. **Tool mocking**: Return mock results during testing
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `action` | must be `respond` |
|
||||
| `call` | modified call information (optional) |
|
||||
| `result` | tool result to return directly |
|
||||
|
||||
---
|
||||
|
||||
## 5. `hook.after_tool`
|
||||
|
||||
Triggered after tool execution completes. Can modify the result returned to LLM.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "hook.after_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "echoed: hello",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"async": false,
|
||||
"media": [],
|
||||
"artifact_tags": [],
|
||||
"response_handled": false
|
||||
},
|
||||
"duration": 15000000,
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `result.for_llm` | content returned to LLM |
|
||||
| `result.for_user` | content sent to user |
|
||||
| `result.silent` | whether silent (not sent to user) |
|
||||
| `result.is_error` | whether it's an error |
|
||||
| `result.async` | whether executed asynchronously |
|
||||
| `result.media` | list of media references |
|
||||
| `result.artifact_tags` | local artifact path tags |
|
||||
| `result.response_handled` | whether response has been handled |
|
||||
| `duration` | execution time (nanoseconds) |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `hook.approve_tool`
|
||||
|
||||
Approval hook for deciding whether to allow execution of sensitive tools.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"method": "hook.approve_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "bash",
|
||||
"arguments": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Approved)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Denied)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": false,
|
||||
"reason": "Dangerous command, execution denied"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event` (notification)
|
||||
|
||||
Observer event, broadcast only, no response required. `id` is `0` or absent.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
},
|
||||
"Payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common `Kind` values:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## Action Options
|
||||
|
||||
| action | Applicable hooks | Effect |
|
||||
|--------|-----------------|--------|
|
||||
| `continue` | All interceptor types | Pass through without modification |
|
||||
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | Modify request/response and pass through |
|
||||
| `respond` | `before_tool` | Return tool result directly, skip actual execution. **Note: AfterTool is NOT called (design decision - respond provides final answer).** |
|
||||
| `deny_tool` | `before_tool` | Deny tool execution |
|
||||
| `abort_turn` | All interceptor types | Abort current turn, return error |
|
||||
| `hard_abort` | All interceptor types | Force stop entire agent loop |
|
||||
|
||||
---
|
||||
|
||||
## Complete Flow Example
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
|
||||
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
|
||||
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
|
||||
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"Files listed"}}}
|
||||
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Tool Injection via `before_llm` and `before_tool`
|
||||
|
||||
Standard flow for plugin tool injection:
|
||||
|
||||
1. In `before_llm`, inject tool definition to let LLM know the tool is available
|
||||
2. In `before_tool`, use `respond` action to return tool execution result directly
|
||||
|
||||
### `before_llm` Inject Tool Definition
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# Add plugin tool definition
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "Plugin provided tool",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": "Input content"}
|
||||
},
|
||||
"required": ["input"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params["model"],
|
||||
"messages": params["messages"],
|
||||
"tools": tools,
|
||||
"options": params.get("options", {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` Return Execution Result
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# Implement tool logic here
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
# Return result directly, no need to register in ToolRegistry
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"Plugin tool executed successfully, input: {input_text}",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw.
|
||||
@@ -0,0 +1,568 @@
|
||||
# Hook JSON-RPC 协议详解
|
||||
|
||||
所有 hook 使用 `JSON-RPC 2.0` 格式,每行一个 JSON 消息,通过 stdio 传输。
|
||||
|
||||
---
|
||||
|
||||
## 基础协议结构
|
||||
|
||||
### 请求(PicoClaw → Hook)
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.xxx","params":{...}}
|
||||
```
|
||||
|
||||
### 响应(Hook → PicoClaw)
|
||||
|
||||
成功:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"result":{...}}
|
||||
```
|
||||
|
||||
错误:
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"错误信息"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `hook.hello`(握手)
|
||||
|
||||
启动时必须完成握手,否则 hook 进程会被终止。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "hook.hello",
|
||||
"params": {
|
||||
"name": "py_review_gate",
|
||||
"version": 1,
|
||||
"modes": ["observe", "tool", "approve"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | hook 名称(来自配置) |
|
||||
| `version` | 协议版本,当前为 `1` |
|
||||
| `modes` | hook 支持的能力模式 |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"name": "python-review-gate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `hook.before_llm`
|
||||
|
||||
在发送请求给 LLM 之前触发。可用于注入工具。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "hook.before_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"ParentTurnID": "",
|
||||
"SessionKey": "session-1",
|
||||
"Iteration": 0,
|
||||
"TracePath": "runTurn",
|
||||
"Source": "turn.llm.request"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo text",
|
||||
"parameters": {"type": "object"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"temperature": 0.7
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1",
|
||||
"graceful_terminal": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meta` | 事件元数据,用于追踪 |
|
||||
| `model` | 请求的模型名称 |
|
||||
| `messages` | 对话历史 |
|
||||
| `tools` | 可用工具定义列表 |
|
||||
| `options` | LLM 参数(temperature、max_tokens 等) |
|
||||
| `channel` | 请求来源通道 |
|
||||
| `chat_id` | 会话 ID |
|
||||
|
||||
### 响应(注入工具示例)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"description": "echo",
|
||||
"parameters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "插件注入的工具",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `action` | 决策动作(见下表) |
|
||||
| `request` | 修改后的请求对象 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `hook.after_llm`
|
||||
|
||||
在收到 LLM 响应后触发。可修改响应内容。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "hook.after_llm",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"model": "claude-sonnet",
|
||||
"response": {
|
||||
"role": "assistant",
|
||||
"content": "Hi!",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "tc-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "echo",
|
||||
"arguments": "{\"text\":\"hi\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `hook.before_tool`
|
||||
|
||||
在执行工具前触发。可修改工具名称和参数,或拒绝执行,或直接返回结果。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "hook.before_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `tool` | 工具名称 |
|
||||
| `arguments` | 工具参数 |
|
||||
|
||||
### 响应(改写参数)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "modify",
|
||||
"call": {
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "modified hello"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(拒绝执行)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "deny_tool",
|
||||
"reason": "参数不合法"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(直接返回结果 - respond)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"result": {
|
||||
"action": "respond",
|
||||
"call": {
|
||||
"tool": "my_plugin_tool",
|
||||
"arguments": {
|
||||
"query": "hello"
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "Plugin tool executed successfully",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`respond` action 允许 hook 直接返回工具结果,跳过实际工具执行。适用于:
|
||||
1. **插件工具注入**:外部 hook 可实现工具,无需在 ToolRegistry 注册
|
||||
2. **工具结果缓存**:对重复调用返回缓存结果
|
||||
3. **工具模拟**:测试时返回模拟结果
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `action` | 必须为 `respond` |
|
||||
| `call` | 修改后的调用信息(可选) |
|
||||
| `result` | 直接返回的工具结果 |
|
||||
|
||||
---
|
||||
|
||||
## 5. `hook.after_tool`
|
||||
|
||||
在工具执行完成后触发。可修改返回给 LLM 的结果。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "hook.after_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "echo_text",
|
||||
"arguments": {
|
||||
"text": "hello"
|
||||
},
|
||||
"result": {
|
||||
"for_llm": "echoed: hello",
|
||||
"for_user": "",
|
||||
"silent": false,
|
||||
"is_error": false,
|
||||
"async": false,
|
||||
"media": [],
|
||||
"artifact_tags": [],
|
||||
"response_handled": false
|
||||
},
|
||||
"duration": 15000000,
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result.for_llm` | 返回给 LLM 的内容 |
|
||||
| `result.for_user` | 发送给用户的内容 |
|
||||
| `result.silent` | 是否静默(不发送给用户) |
|
||||
| `result.is_error` | 是否为错误 |
|
||||
| `result.async` | 是否异步执行 |
|
||||
| `result.media` | 媒体引用列表 |
|
||||
| `result.artifact_tags` | 本地产物路径标签 |
|
||||
| `result.response_handled` | 是否已处理响应 |
|
||||
| `duration` | 执行耗时(纳秒) |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"result": {
|
||||
"action": "continue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `hook.approve_tool`
|
||||
|
||||
审批型 hook,用于决定是否允许执行敏感工具。
|
||||
|
||||
### 请求
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"method": "hook.approve_tool",
|
||||
"params": {
|
||||
"meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1",
|
||||
"SessionKey": "session-1"
|
||||
},
|
||||
"tool": "bash",
|
||||
"arguments": {
|
||||
"command": "rm -rf /"
|
||||
},
|
||||
"channel": "cli",
|
||||
"chat_id": "chat-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(批准)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应(拒绝)
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 6,
|
||||
"result": {
|
||||
"approved": false,
|
||||
"reason": "危险命令,禁止执行"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. `hook.event`(notification)
|
||||
|
||||
观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "hook.event",
|
||||
"params": {
|
||||
"Kind": "tool_exec_start",
|
||||
"Meta": {
|
||||
"AgentID": "agent-1",
|
||||
"TurnID": "turn-1"
|
||||
},
|
||||
"Payload": {
|
||||
"Tool": "echo_text",
|
||||
"Arguments": {"text": "hello"}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见 `Kind` 值:
|
||||
- `turn_start` / `turn_end`
|
||||
- `llm_request` / `llm_response`
|
||||
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped`
|
||||
- `steering_injected`
|
||||
- `interrupt_received`
|
||||
- `error`
|
||||
|
||||
---
|
||||
|
||||
## action 可选值
|
||||
|
||||
| action | 适用 hook | 效果 |
|
||||
|--------|----------|------|
|
||||
| `continue` | 所有拦截型 | 放行,不做修改 |
|
||||
| `modify` | `before_llm`, `before_tool`, `after_llm`, `after_tool` | 改写请求/响应后放行 |
|
||||
| `respond` | `before_tool` | 直接返回工具结果,跳过实际执行 |
|
||||
| `deny_tool` | `before_tool` | 拒绝执行该工具 |
|
||||
| `abort_turn` | 所有拦截型 | 中止当前 turn,返回错误 |
|
||||
| `hard_abort` | 所有拦截型 | 强制终止整个 agent loop |
|
||||
|
||||
---
|
||||
|
||||
## 完整流程示例
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":1,"method":"hook.hello","params":{"name":"my_hook","version":1,"modes":["tool","approve"]}}
|
||||
{"jsonrpc":"2.0","id":1,"result":{"ok":true,"name":"my_hook"}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"hook.before_llm","params":{"model":"claude-sonnet","messages":[{"role":"user","content":"hello"}],"tools":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"hook.before_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":4,"method":"hook.approve_tool","params":{"tool":"bash","arguments":{"command":"ls"}}}
|
||||
{"jsonrpc":"2.0","id":4,"result":{"approved":true}}
|
||||
{"jsonrpc":"2.0","id":5,"method":"hook.after_tool","params":{"tool":"bash","arguments":{"command":"ls"},"result":{"for_llm":"file1.txt\nfile2.txt"},"duration":5000000}}
|
||||
{"jsonrpc":"2.0","id":5,"result":{"action":"continue"}}
|
||||
{"jsonrpc":"2.0","id":6,"method":"hook.after_llm","params":{"model":"claude-sonnet","response":{"role":"assistant","content":"已列出文件"}}}
|
||||
{"jsonrpc":"2.0","id":6,"result":{"action":"continue"}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通过 `before_llm` 和 `before_tool` 实现插件工具注入
|
||||
|
||||
插件工具注入的标准流程:
|
||||
|
||||
1. 在 `before_llm` 中注入工具定义,让 LLM 知道有这个工具可用
|
||||
2. 在 `before_tool` 中使用 `respond` action 直接返回工具执行结果
|
||||
|
||||
### `before_llm` 注入工具定义
|
||||
|
||||
```python
|
||||
def handle_before_llm(params: dict) -> dict:
|
||||
tools = params.get("tools", [])
|
||||
|
||||
# 添加插件工具定义
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_plugin_tool",
|
||||
"description": "插件提供的工具",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": "输入内容"}
|
||||
},
|
||||
"required": ["input"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"action": "modify",
|
||||
"request": {
|
||||
"model": params["model"],
|
||||
"messages": params["messages"],
|
||||
"tools": tools,
|
||||
"options": params.get("options", {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `before_tool` 返回执行结果
|
||||
|
||||
```python
|
||||
def handle_before_tool(params: dict) -> dict:
|
||||
tool = params.get("tool", "")
|
||||
|
||||
if tool == "my_plugin_tool":
|
||||
# 在这里实现工具逻辑
|
||||
args = params.get("arguments", {})
|
||||
input_text = args.get("input", "")
|
||||
|
||||
# 直接返回结果,无需在 ToolRegistry 注册
|
||||
return {
|
||||
"action": "respond",
|
||||
"result": {
|
||||
"for_llm": f"插件工具执行成功,输入: {input_text}",
|
||||
"silent": False,
|
||||
"is_error": False
|
||||
}
|
||||
}
|
||||
|
||||
return {"action": "continue"}
|
||||
```
|
||||
|
||||
通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。
|
||||
@@ -0,0 +1,587 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,587 @@
|
||||
# 插件工具注入示例
|
||||
|
||||
本文档展示如何利用 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 只影响插件工具,不影响系统工具的审批流程。
|
||||
Reference in New Issue
Block a user