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:
parent
4d34824737
commit
bffa9a14da
5 changed files with 300 additions and 6 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue