feat: AgentExecutor — elf-based hook evaluation via elf.Manager
This commit is contained in:
55
internal/hook/agent.go
Normal file
55
internal/hook/agent.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/elf"
|
||||
"somegit.dev/Owlibou/gnoma/internal/router"
|
||||
)
|
||||
|
||||
// ElfSpawner is the minimal interface AgentExecutor needs from elf.Manager.
|
||||
type ElfSpawner interface {
|
||||
Spawn(ctx context.Context, taskType router.TaskType, prompt, systemPrompt string, maxTurns int) (elf.Elf, error)
|
||||
}
|
||||
|
||||
// AgentExecutor spawns an elf and parses ALLOW/DENY from its output.
|
||||
type AgentExecutor struct {
|
||||
def HookDef
|
||||
spawner ElfSpawner
|
||||
}
|
||||
|
||||
// NewAgentExecutor constructs an AgentExecutor.
|
||||
func NewAgentExecutor(def HookDef, spawner ElfSpawner) *AgentExecutor {
|
||||
return &AgentExecutor{def: def, spawner: spawner}
|
||||
}
|
||||
|
||||
// Execute renders the hook template, spawns an elf, waits for its result,
|
||||
// and returns Allow/Deny/Skip based on the output.
|
||||
func (a *AgentExecutor) Execute(ctx context.Context, payload []byte) (HookResult, error) {
|
||||
data := templateDataFromPayload(payload, a.def.Event)
|
||||
prompt, err := renderTemplate(a.def.Exec, data)
|
||||
if err != nil {
|
||||
return HookResult{}, fmt.Errorf("hook %q: %w", a.def.Name, err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
e, err := a.spawner.Spawn(ctx, router.TaskReview, prompt, "", 5)
|
||||
if err != nil {
|
||||
return HookResult{}, fmt.Errorf("hook %q: spawn elf: %w", a.def.Name, err)
|
||||
}
|
||||
|
||||
result := e.Wait()
|
||||
duration := time.Since(start)
|
||||
|
||||
if result.Error != nil {
|
||||
return HookResult{Duration: duration}, fmt.Errorf("hook %q: elf failed: %w", a.def.Name, result.Error)
|
||||
}
|
||||
|
||||
action := parseDecision(result.Output)
|
||||
return HookResult{
|
||||
Action: action,
|
||||
Duration: duration,
|
||||
}, nil
|
||||
}
|
||||
139
internal/hook/agent_test.go
Normal file
139
internal/hook/agent_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/elf"
|
||||
"somegit.dev/Owlibou/gnoma/internal/router"
|
||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||
)
|
||||
|
||||
// mockElfSpawner satisfies ElfSpawner. Records calls and returns configurable results.
|
||||
type mockElfSpawner struct {
|
||||
result elf.Result
|
||||
err error
|
||||
// Captures
|
||||
lastPrompt string
|
||||
lastTask router.TaskType
|
||||
}
|
||||
|
||||
func (m *mockElfSpawner) Spawn(ctx context.Context, taskType router.TaskType, prompt, systemPrompt string, maxTurns int) (elf.Elf, error) {
|
||||
m.lastPrompt = prompt
|
||||
m.lastTask = taskType
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return &immediateElf{result: m.result}, nil
|
||||
}
|
||||
|
||||
// immediateElf returns a pre-computed result immediately.
|
||||
type immediateElf struct {
|
||||
result elf.Result
|
||||
}
|
||||
|
||||
func (e *immediateElf) ID() string { return "test-elf" }
|
||||
func (e *immediateElf) Status() elf.Status { return e.result.Status }
|
||||
func (e *immediateElf) Events() <-chan stream.Event { return nil }
|
||||
func (e *immediateElf) Wait() elf.Result { return e.result }
|
||||
func (e *immediateElf) Cancel() {}
|
||||
|
||||
func TestAgentExecutor_OutputALLOW(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review this tool call."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "After analysis, ALLOW this.", Status: elf.StatusCompleted}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
result, err := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Action != Allow {
|
||||
t.Errorf("action = %v, want Allow", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_OutputDENY(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review this."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "This is dangerous. DENY.", Status: elf.StatusCompleted}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
result, err := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Action != Deny {
|
||||
t.Errorf("action = %v, want Deny", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_OutputNoMatch_Skip(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review this."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "I'm unsure.", Status: elf.StatusCompleted}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
result, err := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Action != Skip {
|
||||
t.Errorf("action = %v, want Skip", result.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_ElfFailure_Error(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{
|
||||
Output: "",
|
||||
Status: elf.StatusFailed,
|
||||
Error: errors.New("elf crashed"),
|
||||
}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
_, err := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if err == nil {
|
||||
t.Error("expected error for failed elf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_SpawnError(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review."}
|
||||
spawner := &mockElfSpawner{err: errors.New("no arms available")}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
_, err := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if err == nil {
|
||||
t.Error("expected error when spawn fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_TemplateRendered(t *testing.T) {
|
||||
def := HookDef{
|
||||
Name: "test",
|
||||
Event: PreToolUse,
|
||||
Command: CommandTypeAgent,
|
||||
Exec: "Tool={{.Tool}} Event={{.Event}}",
|
||||
}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "ALLOW", Status: elf.StatusCompleted}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if spawner.lastPrompt != "Tool=bash Event=pre_tool_use" {
|
||||
t.Errorf("prompt = %q", spawner.lastPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_Duration(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "ALLOW", Status: elf.StatusCompleted, Duration: 100 * time.Millisecond}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
result, _ := ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if result.Duration <= 0 {
|
||||
t.Error("expected Duration > 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutor_TaskTypeIsReview(t *testing.T) {
|
||||
def := HookDef{Name: "test", Event: PreToolUse, Command: CommandTypeAgent, Exec: "Review."}
|
||||
spawner := &mockElfSpawner{result: elf.Result{Output: "ALLOW", Status: elf.StatusCompleted}}
|
||||
ex := NewAgentExecutor(def, spawner)
|
||||
ex.Execute(context.Background(), MarshalPreToolPayload("bash", nil))
|
||||
if spawner.lastTask != router.TaskReview {
|
||||
t.Errorf("task type = %v, want TaskReview", spawner.lastTask)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user