Files
kit/pkg/kit/hooks.go
Ed Zynda ac8ee6525d refactor(pkg/kit): replace fantasy type aliases with concrete LLM* structs
Remove charm.land/fantasy from the public API surface of pkg/kit by
replacing the four type aliases with concrete Kit-owned structs:

- LLMMessage  {Role LLMMessageRole, Content string}
- LLMUsage    {InputTokens, OutputTokens, TotalTokens, ...}
- LLMResponse {Content, FinishReason, Usage}
- LLMFilePart {Filename, Data []byte, MediaType}

Add LLMMessageRole type with user/assistant/system/tool constants.

Introduce pkg/kit/llm_convert.go as the single boundary layer where
Kit types convert to/from fantasy types internally. All callers in
pkg/kit, pkg/kit/compaction.go, pkg/kit/extensions_bridge.go, and
internal/app/app.go cross through this layer.

ContextPrepareHook.Messages and ContextPrepareResult.Messages change
from []fantasy.Message to []LLMMessage. extensions_bridge.go drops
its fantasy and strings imports entirely.

internal/app/app_test.go switches &fantasy.Usage{} to &kit.LLMUsage{}.

Add seven new tests in types_test.go covering concrete construction,
role constants, JSON snake_case tags, and round-trip conversion.
2026-03-31 13:44:05 +03:00

330 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package kit
import (
"context"
"fmt"
"sort"
"sync"
"charm.land/fantasy"
)
// ---------------------------------------------------------------------------
// Priority
// ---------------------------------------------------------------------------
// HookPriority controls execution order of hooks. Lower values run first.
type HookPriority int
const (
// HookPriorityHigh runs before normal hooks.
HookPriorityHigh HookPriority = 0
// HookPriorityNormal is the default priority.
HookPriorityNormal HookPriority = 50
// HookPriorityLow runs after normal hooks.
HookPriorityLow HookPriority = 100
)
// ---------------------------------------------------------------------------
// Hook input/result types
// ---------------------------------------------------------------------------
// BeforeToolCallHook is the input for hooks that fire before a tool executes.
type BeforeToolCallHook struct {
ToolCallID string
ToolName string
ToolArgs string
}
// BeforeToolCallResult controls whether the tool call proceeds.
type BeforeToolCallResult struct {
Block bool // true prevents the tool from running
Reason string // human-readable reason for blocking
}
// AfterToolResultHook is the input for hooks that fire after a tool executes.
type AfterToolResultHook struct {
ToolCallID string
ToolName string
ToolArgs string
Result string
IsError bool
}
// AfterToolResultResult can modify the tool's output before it reaches the LLM.
type AfterToolResultResult struct {
Result *string // non-nil overrides the result text
IsError *bool // non-nil overrides the error flag
}
// BeforeTurnHook is the input for hooks that fire before a prompt turn.
type BeforeTurnHook struct {
Prompt string
}
// BeforeTurnResult can modify the prompt, inject system messages, or add context.
type BeforeTurnResult struct {
Prompt *string // override prompt text in the user message
SystemPrompt *string // prepend a system message
InjectText *string // prepend a user context message
}
// AfterTurnHook is the input for hooks that fire after a prompt turn completes.
type AfterTurnHook struct {
Response string
Error error
}
// AfterTurnResult is a placeholder — after-turn hooks are observation-only.
type AfterTurnResult struct{}
// ContextPrepareHook is the input for hooks that fire after the context window
// is assembled from the session tree (including compaction) and before the
// messages are sent to the LLM. Hooks can filter, reorder, or inject messages.
type ContextPrepareHook struct {
// Messages is the current context as LLM message objects.
Messages []LLMMessage
}
// ContextPrepareResult can replace the context window.
type ContextPrepareResult struct {
// Messages replaces the entire context window. If nil, the original
// messages are used.
Messages []LLMMessage
}
// BeforeCompactHook is the input for hooks that fire before compaction runs.
type BeforeCompactHook struct {
// EstimatedTokens is the estimated token count of the conversation.
EstimatedTokens int
// ContextLimit is the model's context window size in tokens.
ContextLimit int
// UsagePercent is the fraction of context used (0.01.0).
UsagePercent float64
// MessageCount is the number of messages in the conversation.
MessageCount int
// IsAutomatic is true when compaction was triggered automatically.
IsAutomatic bool
}
// BeforeCompactResult controls whether compaction proceeds. Extensions can
// cancel compaction or provide a custom summary that replaces the default
// LLM-generated one.
type BeforeCompactResult struct {
// Cancel, when true, prevents compaction from proceeding.
Cancel bool
// Reason is a human-readable explanation when Cancel is true.
Reason string
// Summary, when non-empty, replaces the default LLM-generated summary.
// The extension is responsible for generating a useful summary.
// Ignored when Cancel is true.
Summary string
}
// ---------------------------------------------------------------------------
// Generic hook registry with priority ordering
// ---------------------------------------------------------------------------
type hookEntry[In any, Out any] struct {
id int
priority HookPriority
handler func(In) *Out
}
type hookRegistry[In any, Out any] struct {
mu sync.RWMutex
hooks []hookEntry[In, Out]
next int
}
func newHookRegistry[In any, Out any]() *hookRegistry[In, Out] {
return &hookRegistry[In, Out]{}
}
// register adds a hook with the given priority and returns an unregister
// function. Within the same priority, hooks run in registration order.
func (hr *hookRegistry[In, Out]) register(p HookPriority, h func(In) *Out) func() {
hr.mu.Lock()
id := hr.next
hr.next++
hr.hooks = append(hr.hooks, hookEntry[In, Out]{id: id, priority: p, handler: h})
// Stable sort preserves insertion order within the same priority.
sort.SliceStable(hr.hooks, func(i, j int) bool {
return hr.hooks[i].priority < hr.hooks[j].priority
})
hr.mu.Unlock()
return func() {
hr.mu.Lock()
defer hr.mu.Unlock()
for i, entry := range hr.hooks {
if entry.id == id {
hr.hooks = append(hr.hooks[:i], hr.hooks[i+1:]...)
return
}
}
}
}
// run executes all hooks in priority order. The first non-nil result wins.
// Returns nil immediately if no hooks are registered.
func (hr *hookRegistry[In, Out]) run(input In) *Out {
hr.mu.RLock()
if len(hr.hooks) == 0 {
hr.mu.RUnlock()
return nil
}
snapshot := make([]hookEntry[In, Out], len(hr.hooks))
copy(snapshot, hr.hooks)
hr.mu.RUnlock()
for _, entry := range snapshot {
if result := entry.handler(input); result != nil {
return result
}
}
return nil
}
// hasHooks returns true if any hooks are registered.
func (hr *hookRegistry[In, Out]) hasHooks() bool {
hr.mu.RLock()
defer hr.mu.RUnlock()
return len(hr.hooks) > 0
}
// ---------------------------------------------------------------------------
// Hook registration methods on Kit
// ---------------------------------------------------------------------------
// OnBeforeToolCall registers a hook that fires before each tool execution.
// Return a non-nil BeforeToolCallResult with Block=true to prevent the tool
// from running. Hooks execute in priority order; the first non-nil result wins.
// Returns an unregister function.
func (m *Kit) OnBeforeToolCall(p HookPriority, h func(BeforeToolCallHook) *BeforeToolCallResult) func() {
return m.beforeToolCall.register(p, h)
}
// OnAfterToolResult registers a hook that fires after each tool execution.
// Return a non-nil AfterToolResultResult to modify the tool's output before
// it reaches the LLM. Hooks execute in priority order; the first non-nil
// result wins. Returns an unregister function.
func (m *Kit) OnAfterToolResult(p HookPriority, h func(AfterToolResultHook) *AfterToolResultResult) func() {
return m.afterToolResult.register(p, h)
}
// OnBeforeTurn registers a hook that fires before each prompt turn. Return
// a non-nil BeforeTurnResult to modify the prompt, inject a system message,
// or prepend context. Hooks execute in priority order; the first non-nil
// result wins. Returns an unregister function.
func (m *Kit) OnBeforeTurn(p HookPriority, h func(BeforeTurnHook) *BeforeTurnResult) func() {
return m.beforeTurn.register(p, h)
}
// OnAfterTurn registers a hook that fires after each prompt turn completes.
// This is observation-only — the handler cannot modify the response. Hooks
// execute in priority order. Returns an unregister function.
func (m *Kit) OnAfterTurn(p HookPriority, h func(AfterTurnHook)) func() {
return m.afterTurn.register(p, func(input AfterTurnHook) *AfterTurnResult {
h(input)
return nil
})
}
// OnContextPrepare registers a hook that fires after the context window is
// built from the session tree and before messages are sent to the LLM. Return
// a non-nil ContextPrepareResult with Messages to replace the entire context.
// Hooks execute in priority order; the first non-nil result wins.
// Returns an unregister function.
func (m *Kit) OnContextPrepare(p HookPriority, h func(ContextPrepareHook) *ContextPrepareResult) func() {
return m.contextPrepare.register(p, h)
}
// OnBeforeCompact registers a hook that fires before context compaction runs.
// Return a non-nil BeforeCompactResult with Cancel=true to prevent compaction.
// Hooks execute in priority order; the first non-nil result wins.
// Returns an unregister function.
func (m *Kit) OnBeforeCompact(p HookPriority, h func(BeforeCompactHook) *BeforeCompactResult) func() {
return m.beforeCompact.register(p, h)
}
// ---------------------------------------------------------------------------
// Tool wrapping via hooks
// ---------------------------------------------------------------------------
// hookedTool wraps an AgentTool to run BeforeToolCall and
// AfterToolResult hooks around each execution. The registries are referenced
// by pointer so hooks added after agent creation are still invoked.
type hookedTool struct {
inner fantasy.AgentTool
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult]
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult]
}
func (h *hookedTool) Info() fantasy.ToolInfo { return h.inner.Info() }
func (h *hookedTool) ProviderOptions() fantasy.ProviderOptions { return h.inner.ProviderOptions() }
func (h *hookedTool) SetProviderOptions(o fantasy.ProviderOptions) { h.inner.SetProviderOptions(o) }
func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
toolName := h.inner.Info().Name
// 1. BeforeToolCall — can block execution.
if h.beforeToolCall.hasHooks() {
if result := h.beforeToolCall.run(BeforeToolCallHook{
ToolCallID: call.ID,
ToolName: toolName,
ToolArgs: call.Input,
}); result != nil && result.Block {
reason := result.Reason
if reason == "" {
reason = "blocked by hook"
}
return fantasy.NewTextErrorResponse(fmt.Sprintf("Error: %s", reason)),
fmt.Errorf("tool blocked by hook: %s", reason)
}
}
// 2. Execute actual tool.
resp, err := h.inner.Run(ctx, call)
// 3. AfterToolResult — can modify output.
if h.afterToolResult.hasHooks() {
if result := h.afterToolResult.run(AfterToolResultHook{
ToolCallID: call.ID,
ToolName: toolName,
ToolArgs: call.Input,
Result: resp.Content,
IsError: err != nil || resp.IsError,
}); result != nil {
if result.Result != nil {
resp.Content = *result.Result
}
if result.IsError != nil {
resp.IsError = *result.IsError
}
}
}
return resp, err
}
// hookToolWrapper creates a tool wrapper function that applies hook-based
// tool interception. The wrapper references the hook registries directly,
// so hooks registered after agent creation are still called at execution time.
func hookToolWrapper(
beforeToolCall *hookRegistry[BeforeToolCallHook, BeforeToolCallResult],
afterToolResult *hookRegistry[AfterToolResultHook, AfterToolResultResult],
) func([]fantasy.AgentTool) []fantasy.AgentTool {
return func(tools []fantasy.AgentTool) []fantasy.AgentTool {
wrapped := make([]fantasy.AgentTool, len(tools))
for i, tool := range tools {
wrapped[i] = &hookedTool{
inner: tool,
beforeToolCall: beforeToolCall,
afterToolResult: afterToolResult,
}
}
return wrapped
}
}