mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
ac8ee6525d
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.
330 lines
11 KiB
Go
330 lines
11 KiB
Go
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.0–1.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
|
||
}
|
||
}
|