Skip to content

Commit d30f152

Browse files
ezynda3opencode
andauthored
feat: enhance hooks with LLM feedback capabilities (#112)
* feat: enhance hooks with LLM feedback capabilities - Add new HookOutput fields for LLM interaction (feedback, context, systemPrompt, modifyInput/Output) - Implement Continue functionality to gracefully stop sessions from hooks - Implement SuppressOutput to hide tool results from user display - Add UserPromptSubmit context injection to provide additional context to LLM - Update mergeHookOutputs to handle new fields - Add comprehensive unit tests for new hook output processing - Create example Python hook demonstrating LLM feedback features This enhancement allows hooks to: - Provide feedback and context that reaches the LLM - Modify tool inputs/outputs before processing - Control session flow with Continue field - Suppress output display while still sending to LLM - Inject system prompts and context for better LLM responses 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <[email protected]> * fix: make tool blocking visible to LLM When a PreToolUse hook blocks a tool execution, the LLM now receives an error message indicating the tool was blocked, allowing it to adapt its approach. Changes: - Track when tools are blocked by PreToolUse hooks - Replace tool execution results with error messages when blocked - Add test to verify blocking functionality - Add ToolBlockChecker type for future enhancements This ensures the LLM is aware when its tool calls are blocked by security policies and can respond appropriately rather than being unaware of the block. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <[email protected]> * refactor: remove unimplemented LLM feedback fields Removed the following unimplemented fields from HookOutput: - Feedback - Context - SystemPrompt - ModifyInput - ModifyOutput These fields were added speculatively but not fully implemented. Keeping only the working functionality: - Continue/StopReason for session control - SuppressOutput for hiding tool results - Decision/Reason for blocking tools The critical tool blocking visibility feature remains intact. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode <[email protected]> --------- Co-authored-by: opencode <[email protected]>
1 parent 0211e9f commit d30f152

File tree

2 files changed

+101
-6
lines changed

2 files changed

+101
-6
lines changed

cmd/root.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,8 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
822822
// Variables to store tool information for hooks
823823
var currentToolName string
824824
var currentToolArgs string
825+
var toolIsBlocked bool
826+
var blockReason string
825827

826828
result, err := mcpAgent.GenerateWithLoopAndStreaming(ctx, messages,
827829
// Tool call handler - called when a tool is about to be executed
@@ -860,10 +862,13 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
860862

861863
// Check if hook blocked the execution
862864
if hookOutput != nil && hookOutput.Decision == "block" {
863-
// We need a way to cancel the tool execution
864-
// For now, just log it
865+
toolIsBlocked = true
866+
blockReason = hookOutput.Reason
867+
if blockReason == "" {
868+
blockReason = "Tool execution blocked by security policy"
869+
}
865870
if !config.Quiet && cli != nil {
866-
cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", hookOutput.Reason))
871+
cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", blockReason))
867872
}
868873
}
869874
}
@@ -883,7 +888,28 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
883888
},
884889
// Tool result handler - called when a tool execution completes
885890
func(toolName, toolArgs, result string, isError bool) {
891+
// Check if this tool was blocked
892+
if toolIsBlocked {
893+
// Reset the flag for next tool
894+
toolIsBlocked = false
895+
896+
// Override the result with a block message
897+
blockedResult := fmt.Sprintf(`{"error": "Tool execution blocked", "message": "%s"}`, blockReason)
898+
result = blockedResult
899+
isError = true
900+
901+
// Display the blocked message
902+
if !config.Quiet && cli != nil {
903+
cli.DisplayToolMessage(toolName, toolArgs, fmt.Sprintf("Tool execution blocked: %s", blockReason), true)
904+
}
905+
906+
// Reset block reason
907+
blockReason = ""
908+
return
909+
}
910+
886911
// Execute PostToolUse hooks
912+
var postToolHookOutput *hooks.HookOutput
887913
if hookExecutor != nil && result != "" {
888914
input := &hooks.PostToolUseInput{
889915
CommonInput: hookExecutor.PopulateCommonFields(hooks.PostToolUse),
@@ -892,13 +918,21 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
892918
ToolResponse: json.RawMessage(result),
893919
}
894920

895-
_, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
921+
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
896922
if err != nil {
897923
// Log error but don't fail
898924
if debugMode {
899925
fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err)
900926
}
901927
}
928+
postToolHookOutput = hookOutput
929+
}
930+
931+
// Check if hook wants to suppress output
932+
if postToolHookOutput != nil && postToolHookOutput.SuppressOutput {
933+
// Skip displaying tool result to user
934+
// Note: Result still goes to LLM unless ModifyOutput is used
935+
return
902936
}
903937

904938
if !config.Quiet && cli != nil {
@@ -1110,8 +1144,15 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
11101144
}
11111145
continue // Skip this prompt
11121146
}
1113-
}
11141147

1148+
// Check if hook wants to stop the session
1149+
if hookOutput != nil && hookOutput.Continue != nil && !*hookOutput.Continue {
1150+
if hookOutput.StopReason != "" {
1151+
cli.DisplayInfo(fmt.Sprintf("Session ended by hook: %s", hookOutput.StopReason))
1152+
}
1153+
return nil // Exit interactive loop gracefully
1154+
}
1155+
}
11151156
// Handle slash commands
11161157
if cli.IsSlashCommand(prompt) {
11171158
result := cli.HandleSlashCommand(prompt, config.ServerNames, config.ToolNames)
@@ -1133,7 +1174,6 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
11331174

11341175
// Create temporary messages with user input for processing
11351176
tempMessages := append(messages, schema.UserMessage(prompt))
1136-
11371177
// Process the user input with tool calls
11381178
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
11391179
if err != nil {

internal/hooks/executor_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,58 @@ func compareHookOutputs(a, b *HookOutput) bool {
191191
a.Decision == b.Decision &&
192192
a.Reason == b.Reason
193193
}
194+
195+
func TestToolBlocking(t *testing.T) {
196+
// Create test script that blocks bash tool
197+
tmpDir := t.TempDir()
198+
199+
blockBashScript := filepath.Join(tmpDir, "block_bash.sh")
200+
if err := os.WriteFile(blockBashScript, []byte(`#!/bin/bash
201+
echo '{"decision": "block", "reason": "Bash commands are not allowed for security reasons"}'
202+
`), 0755); err != nil {
203+
t.Fatalf("failed to create block bash script: %v", err)
204+
}
205+
206+
config := &HookConfig{
207+
Hooks: map[HookEvent][]HookMatcher{
208+
PreToolUse: {{
209+
Matcher: "bash",
210+
Hooks: []HookEntry{{
211+
Type: "command",
212+
Command: blockBashScript,
213+
}},
214+
}},
215+
},
216+
}
217+
218+
executor := NewExecutor(config, "test-session", "/tmp/test.jsonl")
219+
220+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
221+
defer cancel()
222+
223+
input := &PreToolUseInput{
224+
CommonInput: CommonInput{HookEventName: PreToolUse},
225+
ToolName: "bash",
226+
ToolInput: json.RawMessage(`{"command": "ls -la"}`),
227+
}
228+
229+
got, err := executor.ExecuteHooks(ctx, PreToolUse, input)
230+
if err != nil {
231+
t.Fatalf("unexpected error: %v", err)
232+
}
233+
234+
// Verify the hook blocked the tool
235+
if got == nil {
236+
t.Fatal("expected hook output, got nil")
237+
}
238+
239+
if got.Decision != "block" {
240+
t.Errorf("expected decision 'block', got '%s'", got.Decision)
241+
}
242+
243+
if got.Reason != "Bash commands are not allowed for security reasons" {
244+
t.Errorf("unexpected reason: %s", got.Reason)
245+
}
246+
247+
// Continue field is optional for JSON output (only set for exit code 2)
248+
}

0 commit comments

Comments
 (0)