diff --git a/internal/hook/agent.go b/internal/hook/agent.go new file mode 100644 index 0000000..504d5ae --- /dev/null +++ b/internal/hook/agent.go @@ -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 +} diff --git a/internal/hook/agent_test.go b/internal/hook/agent_test.go new file mode 100644 index 0000000..bb75797 --- /dev/null +++ b/internal/hook/agent_test.go @@ -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) + } +}