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
This commit is contained in:
harmoon 2026-03-31 10:49:51 +08:00
parent 4d34824737
commit bffa9a14da
5 changed files with 300 additions and 6 deletions

View file

@ -28,6 +28,69 @@ The currently exposed synchronous hook points are:
Everything else is exposed as read-only events. 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 ## Execution Order
`HookManager` sorts hooks like this: `HookManager` sorts hooks like this:

View file

@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
) )
const ( const (
@ -90,7 +91,8 @@ type processHookAfterLLMResponse struct {
type processHookBeforeToolResponse struct { type processHookBeforeToolResponse struct {
processHookDecisionResponse processHookDecisionResponse
Call *ToolCallHookRequest `json:"call,omitempty"` Call *ToolCallHookRequest `json:"call,omitempty"`
Result *tools.ToolResult `json:"result,omitempty"` // Result returned directly by hook (for respond action)
} }
type processHookAfterToolResponse struct { type processHookAfterToolResponse struct {
@ -241,6 +243,10 @@ func (ph *ProcessHook) BeforeTool(
if resp.Call == nil { if resp.Call == nil {
resp.Call = call resp.Call = call
} }
// If hook returned a Result, carry it in ToolCallHookRequest
if resp.Result != nil {
resp.Call.HookResult = resp.Result
}
return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil return resp.Call, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil
} }

View file

@ -25,6 +25,7 @@ type HookAction string
const ( const (
HookActionContinue HookAction = "continue" HookActionContinue HookAction = "continue"
HookActionModify HookAction = "modify" HookActionModify HookAction = "modify"
HookActionRespond HookAction = "respond" // Return result directly, skip tool execution
HookActionDenyTool HookAction = "deny_tool" HookActionDenyTool HookAction = "deny_tool"
HookActionAbortTurn HookAction = "abort_turn" HookActionAbortTurn HookAction = "abort_turn"
HookActionHardAbort HookAction = "hard_abort" HookActionHardAbort HookAction = "hard_abort"
@ -127,11 +128,12 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse {
} }
type ToolCallHookRequest struct { type ToolCallHookRequest struct {
Meta EventMeta `json:"meta"` Meta EventMeta `json:"meta"`
Tool string `json:"tool"` Tool string `json:"tool"`
Arguments map[string]any `json:"arguments,omitempty"` Arguments map[string]any `json:"arguments,omitempty"`
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
ChatID string `json:"chat_id,omitempty"` ChatID string `json:"chat_id,omitempty"`
HookResult *tools.ToolResult `json:"hook_result,omitempty"` // Result returned directly by hook (for respond action)
} }
func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
@ -140,6 +142,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest {
} }
cloned := *r cloned := *r
cloned.Arguments = cloneStringAnyMap(r.Arguments) cloned.Arguments = cloneStringAnyMap(r.Arguments)
cloned.HookResult = cloneToolResult(r.HookResult)
return &cloned return &cloned
} }
@ -382,6 +385,10 @@ func (hm *HookManager) BeforeTool(
if next != nil { if next != nil {
current = next current = next
} }
case HookActionRespond:
// Hook returns result directly, skip tool execution
// Carry HookResult in ToolCallHookRequest and return
return next, decision
case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort: case HookActionDenyTool, HookActionAbortTurn, HookActionHardAbort:
return current, decision return current, decision
default: default:

View file

@ -343,3 +343,171 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) {
t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason) t.Fatalf("expected skipped reason %q, got %q", expected, payload.Reason)
} }
} }
// respondHook is a test hook for testing HookActionRespond functionality
type respondHook struct {
respondTools map[string]bool // tool names to respond to
}
func (h *respondHook) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error) {
if h.respondTools[call.Tool] {
next := call.Clone()
next.HookResult = &tools.ToolResult{
ForLLM: "hook-responded: " + call.Tool,
ForUser: "",
Silent: false,
IsError: false,
}
return next, HookDecision{Action: HookActionRespond}, nil
}
return call, HookDecision{Action: HookActionContinue}, nil
}
func (h *respondHook) AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error) {
// Should not be called since respond skips tool execution
return result, HookDecision{Action: HookActionContinue}, nil
}
func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) {
provider := &toolHookProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
al.RegisterTool(&echoTextTool{})
if err := al.MountHook(NamedHook("respond-hook", &respondHook{
respondTools: map[string]bool{"echo_text": true},
})); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID)
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
UserMessage: "run tool",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
// Verify response comes from hook, not tool
expected := "hook-responded: echo_text"
if resp != expected {
t.Fatalf("expected %q, got %q", expected, resp)
}
// Verify event stream has ToolExecEnd, not actual tool execution
events := collectEventStream(sub.C)
endEvt, ok := findEvent(events, EventKindToolExecEnd)
if !ok {
t.Fatal("expected tool exec end event")
}
payload, ok := endEvt.Payload.(ToolExecEndPayload)
if !ok {
t.Fatalf("expected ToolExecEndPayload, got %T", endEvt.Payload)
}
if payload.Tool != "echo_text" {
t.Fatalf("expected tool echo_text, got %q", payload.Tool)
}
if payload.ForLLMLen != len(expected) {
t.Fatalf("expected ForLLMLen %d, got %d", len(expected), payload.ForLLMLen)
}
}
// denyToolHook tests HookActionDenyTool functionality
type denyToolHook struct {
denyTools map[string]bool
}
func (h *denyToolHook) BeforeTool(
ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error) {
if h.denyTools[call.Tool] {
return call, HookDecision{Action: HookActionDenyTool, Reason: "tool denied by hook"}, nil
}
return call, HookDecision{Action: HookActionContinue}, nil
}
func (h *denyToolHook) AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error) {
return result, HookDecision{Action: HookActionContinue}, nil
}
func TestAgentLoop_Hooks_ToolDenyAction(t *testing.T) {
provider := &toolHookProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
al.RegisterTool(&echoTextTool{})
if err := al.MountHook(NamedHook("deny-hook", &denyToolHook{
denyTools: map[string]bool{"echo_text": true},
})); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
UserMessage: "run tool",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
expected := "Tool execution denied by hook: tool denied by hook"
if resp != expected {
t.Fatalf("expected %q, got %q", expected, resp)
}
}
func TestHookManager_BeforeTool_RespondAction(t *testing.T) {
hm := NewHookManager(nil)
defer hm.Close()
// Register a hook that returns respond action
hook := &respondHook{
respondTools: map[string]bool{"test_tool": true},
}
if err := hm.Mount(NamedHook("respond-test", hook)); err != nil {
t.Fatalf("mount hook: %v", err)
}
// Call BeforeTool
req := &ToolCallHookRequest{
Tool: "test_tool",
Arguments: map[string]any{"arg": "value"},
}
result, decision := hm.BeforeTool(context.Background(), req)
// Verify decision is respond
if decision.Action != HookActionRespond {
t.Fatalf("expected action %q, got %q", HookActionRespond, decision.Action)
}
// Verify HookResult is correctly set
if result.HookResult == nil {
t.Fatal("expected HookResult to be set")
}
if result.HookResult.ForLLM != "hook-responded: test_tool" {
t.Fatalf("unexpected HookResult.ForLLM: %q", result.HookResult.ForLLM)
}
}

View file

@ -2256,6 +2256,56 @@ turnLoop:
toolName = toolReq.Tool toolName = toolReq.Tool
toolArgs = toolReq.Arguments toolArgs = toolReq.Arguments
} }
case HookActionRespond:
// Hook returns result directly, skip tool execution
if toolReq != nil && toolReq.HookResult != nil {
hookResult := toolReq.HookResult
toolDuration := time.Duration(0) // Hook execution time unknown
// Emit ToolExecEnd event
al.emitEvent(
EventKindToolExecEnd,
ts.eventMeta("runTurn", "turn.tool.end"),
ToolExecEndPayload{
Tool: toolName,
Duration: toolDuration,
ForLLMLen: len(hookResult.ContentForLLM()),
ForUserLen: len(hookResult.ForUser),
IsError: hookResult.IsError,
Async: hookResult.Async,
},
)
// Send ForUser content to user
if !hookResult.Silent && hookResult.ForUser != "" && ts.opts.SendResponse {
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: ts.channel,
ChatID: ts.chatID,
Content: hookResult.ForUser,
})
}
// Build tool message
contentForLLM := hookResult.ContentForLLM()
if al.cfg.Tools.IsFilterSensitiveDataEnabled() {
contentForLLM = al.cfg.FilterSensitiveData(contentForLLM)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: contentForLLM,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
ts.recordPersistedMessage(toolResultMsg)
}
// Skip subsequent tool execution flow
continue
}
// If no HookResult, fall back to continue
case HookActionDenyTool: case HookActionDenyTool:
allResponsesHandled = false allResponsesHandled = false
denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason)