From e91d3c8b0eeaffc88598fa60c39ed9b0a64c7e1b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 18 Nov 2025 10:30:21 +0100 Subject: [PATCH] wip: show hooks in ui --- internal/agent/coordinator.go | 7 ++ internal/hooks/hooks.go | 66 ++++++++++++++++++- internal/message/content.go | 13 ++-- .../tui/components/chat/messages/messages.go | 32 ++++++++- 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 1dcae362835955cd8d446480432035ed36f4069e..882f75ef6999d41d4503cf20af9fa5941df4935c 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -107,6 +107,12 @@ func NewCoordinator( } c.currentAgent = agent c.agents[config.AgentCoder] = agent + + _, small, err := c.buildAgentModels(ctx) + if err != nil { + return nil, err + } + hooks.SetSmallModel(small.Model) return c, nil } @@ -745,6 +751,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error { return err } c.currentAgent.SetModels(large, small) + c.hooks.SetSmallModel(small.Model) agentCfg, ok := c.cfg.Agents[config.AgentCoder] if !ok { diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 8d3f6101f6b15568c4dbf14426da672bed77d30c..eff72b7501464b4bd040751a61f754db9b696c8f 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "os" "regexp" "strings" "sync" @@ -231,11 +232,71 @@ func (s *service) executeHook(ctx context.Context, hook config.Hook, hookCtx Hoo return nil, fmt.Errorf("unsupported hook type: %s", hook.Type) } + if result != nil { + result.EventType = string(hookCtx.EventType) + } + return result, err } func (s *service) executePromptHook(ctx context.Context, hook config.Hook, hookCtx HookContext) (*message.HookOutput, error) { - panic("not implemented") + contextJSON, err := json.Marshal(hookCtx) + if err != nil { + return nil, fmt.Errorf("failed to marshal hook context: %w", err) + } + + var finalPrompt string + if strings.Contains(hook.Prompt, "$ARGUMENTS") { + finalPrompt = strings.ReplaceAll(hook.Prompt, "$ARGUMENTS", string(contextJSON)) + } else { + finalPrompt = fmt.Sprintf("%s\n\nContext: %s", hook.Prompt, string(contextJSON)) + } + + timeout := DefaultHookTimeout + if hook.Timeout != nil { + timeout = time.Duration(*hook.Timeout) * time.Second + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + type readTranscriptParams struct{} + readTranscriptTool := fantasy.NewAgentTool( + "read_transcript", + "Used to read the conversation so far", + func(ctx context.Context, params readTranscriptParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if hookCtx.TranscriptPath == "" { + return fantasy.NewTextErrorResponse("No transcript available"), nil + } + data, err := os.ReadFile(hookCtx.TranscriptPath) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + return fantasy.NewTextResponse(string(data)), nil + }) + + var output *message.HookOutput + outputTool := fantasy.NewAgentTool( + "output", + "Used to submit the output, remember you MUST call this tool at the end", + func(ctx context.Context, params message.HookOutput, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + output = ¶ms + return fantasy.NewTextResponse("ouptut submitted"), nil + }) + agent := fantasy.NewAgent( + s.smallModel, + fantasy.WithSystemPrompt(`You are a helpful sub agent used in a larger agents conversation loop, + your goal is to intercept the conversation and fulfill the intermediate requests, makesure to ALWAYS use the output tool at the end to output your decision`), + fantasy.WithTools(readTranscriptTool, outputTool), + ) + + _, err = agent.Generate(execCtx, fantasy.AgentCall{ + Prompt: finalPrompt, + }) + if err != nil { + return nil, err + } + return output, nil } func (s *service) executeCommandHook(ctx context.Context, hook config.Hook, hookCtx HookContext) (*message.HookOutput, error) { @@ -342,9 +403,8 @@ func parseHookOutput(stdout string) *message.HookOutput { return &output } -// SetSmallModel implements Service. func (s *service) SetSmallModel(model fantasy.LanguageModel) { - panic("unimplemented") + s.smallModel = model } func (s *service) collectMatchingHooks(hookCtx HookContext) []config.Hook { diff --git a/internal/message/content.go b/internal/message/content.go index 00195debad6ead000ffc659ee2c5e2c508e3b5a4..17a89c4c91cb3b4efcfcbbfcc1474aa76a73af20 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -137,12 +137,13 @@ type Message struct { } type HookOutput struct { - Stop bool `json:"stop"` - Error string `json:"error"` - Message string `json:"message"` - Decision string `json:"decision"` - UpdatedInput string `json:"updated_input"` - AdditionalContext string `json:"additional_context"` + Stop bool `json:"stop,omitempty" description:"set to true if the execution should stop"` + EventType string `json:"event_type" description:"ignore"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty" description:"a message to send to show the user"` + Decision string `json:"decision" description:"block, allow, deny, ask, only set if the request asks you to do so"` + UpdatedInput string `json:"updated_input" description:"the updated tool input json, only set if the user requests you update a tool input"` + AdditionalContext string `json:"additional_context" description:"additional context to send to the LLM, only set if the user asks to add additional context"` } func (m *Message) Content() TextContent { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 54f36fbb0bb71bc4d88de141eaf36bca9bc538ab..345a1a2153c54af672627e51ec34e2ef0b6e9fe5 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -10,6 +10,7 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/ordered" @@ -246,7 +247,36 @@ func (m *messageCmp) renderUserMessage() string { } joined := lipgloss.JoinVertical(lipgloss.Left, parts...) - return m.style().Render(joined) + hooks := m.getHookOutputs(t) + if len(hooks) == 0 { + return m.style().Render(joined) + } + root := tree.Root(joined) + root.EnumeratorStyle(t.S().Subtle) + for _, h := range hooks { + root.Child(h) + } + return m.style().Render(root.Enumerator(RoundedEnumeratorWithWidth(0, 1)).String()) +} + +func (m *messageCmp) getHookOutputs(t *styles.Theme) []string { + var hooks []string + for _, h := range m.message.HookOutputs { + var hookStatus []string + hookStatus = append(hookStatus, t.S().Base.Foreground(t.Blue).Render(fmt.Sprintf(" hook:%s", h.EventType))) + if h.Stop { + hookStatus = append(hookStatus, t.S().Error.Render("hook stopped execution")) + } else if h.AdditionalContext != "" { + hookStatus = append(hookStatus, t.S().Subtle.Render("additional context added")) + } + + if h.Message != "" { + hookStatus = append(hookStatus, t.S().Muted.Render(h.Message)) + } + hookOutput := ansi.Truncate(strings.Join(hookStatus, " "), m.textWidth()-2, "…") + hooks = append(hooks, hookOutput) + } + return hooks } // toMarkdown converts text content to rendered markdown using the configured renderer