agent.go

  1package chat
  2
  3import (
  4	"encoding/json"
  5	"strings"
  6
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"charm.land/lipgloss/v2/tree"
 10	"github.com/charmbracelet/crush/internal/agent"
 11	"github.com/charmbracelet/crush/internal/message"
 12	"github.com/charmbracelet/crush/internal/ui/anim"
 13	"github.com/charmbracelet/crush/internal/ui/styles"
 14)
 15
 16// -----------------------------------------------------------------------------
 17// Agent Tool
 18// -----------------------------------------------------------------------------
 19
 20// NestedToolContainer is an interface for tool items that can contain nested tool calls.
 21type NestedToolContainer interface {
 22	NestedTools() []ToolMessageItem
 23	SetNestedTools(tools []ToolMessageItem)
 24	AddNestedTool(tool ToolMessageItem)
 25}
 26
 27// AgentToolMessageItem is a message item that represents an agent tool call.
 28type AgentToolMessageItem struct {
 29	*baseToolMessageItem
 30
 31	nestedTools []ToolMessageItem
 32}
 33
 34var (
 35	_ ToolMessageItem     = (*AgentToolMessageItem)(nil)
 36	_ NestedToolContainer = (*AgentToolMessageItem)(nil)
 37)
 38
 39// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
 40func NewAgentToolMessageItem(
 41	sty *styles.Styles,
 42	toolCall message.ToolCall,
 43	result *message.ToolResult,
 44	canceled bool,
 45) *AgentToolMessageItem {
 46	t := &AgentToolMessageItem{}
 47	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
 48	// For the agent tool we keep spinning until the tool call is finished.
 49	t.spinningFunc = func(state SpinningState) bool {
 50		return !state.HasResult() && !state.IsCanceled()
 51	}
 52	return t
 53}
 54
 55// Animate progresses the message animation if it should be spinning.
 56func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 57	if a.result != nil || a.Status() == ToolStatusCanceled {
 58		return nil
 59	}
 60	if msg.ID == a.ID() {
 61		return a.anim.Animate(msg)
 62	}
 63	for _, nestedTool := range a.nestedTools {
 64		if msg.ID != nestedTool.ID() {
 65			continue
 66		}
 67		if s, ok := nestedTool.(Animatable); ok {
 68			return s.Animate(msg)
 69		}
 70	}
 71	return nil
 72}
 73
 74// NestedTools returns the nested tools.
 75func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
 76	return a.nestedTools
 77}
 78
 79// SetNestedTools sets the nested tools.
 80//
 81// SetNestedTools always bumps the version. The previous design
 82// deduped when the slice's length and element pointers were
 83// unchanged, but the live update path in internal/ui/model/ui.go
 84// mutates existing children in place (SetToolCall / SetResult on the
 85// same pointers) and then calls SetNestedTools with the same slice.
 86// Pointer-equality dedupe in that case skips the parent Bump even
 87// though the parent's rendered output (which embeds the children
 88// inline) has changed, leaving a stale parent entry in the list
 89// cache. Always bumping is cheap (one uint64 increment) and called
 90// at most once per agent event; in the rare case the slice is
 91// truly unchanged the worst case is one extra parent re-render
 92// while every child cache hit stays warm.
 93func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
 94	a.nestedTools = tools
 95	a.clearCache()
 96	a.Bump()
 97}
 98
 99// AddNestedTool adds a nested tool.
100func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
101	// Mark nested tools as simple (compact) rendering.
102	if s, ok := tool.(Compactable); ok {
103		s.SetCompact(true)
104	}
105	a.nestedTools = append(a.nestedTools, tool)
106	a.clearCache()
107	a.Bump()
108}
109
110// AgentToolRenderContext renders agent tool messages.
111type AgentToolRenderContext struct {
112	agent *AgentToolMessageItem
113}
114
115// RenderTool implements the [ToolRenderer] interface.
116func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
117	cappedWidth := cappedMessageWidth(width)
118	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
119		return pendingTool(sty, "Agent", opts.Anim, opts.Compact)
120	}
121
122	var params agent.AgentParams
123	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
124
125	prompt := params.Prompt
126	prompt = strings.ReplaceAll(prompt, "\n", " ")
127
128	header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
129	if opts.Compact {
130		return header
131	}
132
133	// Build the task tag and prompt.
134	taskTag := sty.Tool.AgentTaskTag.Render("Task")
135	taskTagWidth := lipgloss.Width(taskTag)
136
137	// Calculate remaining width for prompt.
138	remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
139
140	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
141
142	header = lipgloss.JoinVertical(
143		lipgloss.Left,
144		header,
145		"",
146		lipgloss.JoinHorizontal(
147			lipgloss.Left,
148			taskTag,
149			" ",
150			promptText,
151		),
152	)
153
154	// Build tree with nested tool calls.
155	childTools := tree.Root(header)
156
157	for _, nestedTool := range r.agent.nestedTools {
158		childView := nestedTool.Render(remainingWidth)
159		childTools.Child(childView)
160	}
161
162	// Build parts.
163	var parts []string
164	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
165
166	// Show animation if still running.
167	if !opts.HasResult() && !opts.IsCanceled() {
168		parts = append(parts, "", opts.Anim.Render())
169	}
170
171	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
172
173	// Add body content when completed.
174	if opts.HasResult() && opts.Result.Content != "" {
175		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
176		return joinToolParts(result, body)
177	}
178
179	return result
180}
181
182// -----------------------------------------------------------------------------
183// Agentic Fetch Tool
184// -----------------------------------------------------------------------------
185
186// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
187type AgenticFetchToolMessageItem struct {
188	*baseToolMessageItem
189
190	nestedTools []ToolMessageItem
191}
192
193var (
194	_ ToolMessageItem     = (*AgenticFetchToolMessageItem)(nil)
195	_ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
196)
197
198// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
199func NewAgenticFetchToolMessageItem(
200	sty *styles.Styles,
201	toolCall message.ToolCall,
202	result *message.ToolResult,
203	canceled bool,
204) *AgenticFetchToolMessageItem {
205	t := &AgenticFetchToolMessageItem{}
206	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
207	// For the agentic fetch tool we keep spinning until the tool call is finished.
208	t.spinningFunc = func(state SpinningState) bool {
209		return !state.HasResult() && !state.IsCanceled()
210	}
211	return t
212}
213
214// NestedTools returns the nested tools.
215func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
216	return a.nestedTools
217}
218
219// SetNestedTools sets the nested tools. Always bumps the version;
220// see [AgentToolMessageItem.SetNestedTools] for the rationale.
221func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
222	a.nestedTools = tools
223	a.clearCache()
224	a.Bump()
225}
226
227// AddNestedTool adds a nested tool.
228func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
229	// Mark nested tools as simple (compact) rendering.
230	if s, ok := tool.(Compactable); ok {
231		s.SetCompact(true)
232	}
233	a.nestedTools = append(a.nestedTools, tool)
234	a.clearCache()
235	a.Bump()
236}
237
238// AgenticFetchToolRenderContext renders agentic fetch tool messages.
239type AgenticFetchToolRenderContext struct {
240	fetch *AgenticFetchToolMessageItem
241}
242
243// agenticFetchParams matches tools.AgenticFetchParams.
244type agenticFetchParams struct {
245	URL    string `json:"url,omitempty"`
246	Prompt string `json:"prompt"`
247}
248
249// RenderTool implements the [ToolRenderer] interface.
250func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
251	cappedWidth := cappedMessageWidth(width)
252	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
253		return pendingTool(sty, "Agentic Fetch", opts.Anim, opts.Compact)
254	}
255
256	var params agenticFetchParams
257	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
258
259	prompt := params.Prompt
260	prompt = strings.ReplaceAll(prompt, "\n", " ")
261
262	// Build header with optional URL param.
263	var toolParams []string
264	if params.URL != "" {
265		toolParams = append(toolParams, params.URL)
266	}
267
268	header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
269	if opts.Compact {
270		return header
271	}
272
273	// Build the prompt tag.
274	promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
275	promptTagWidth := lipgloss.Width(promptTag)
276
277	// Calculate remaining width for prompt text.
278	remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
279
280	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
281
282	header = lipgloss.JoinVertical(
283		lipgloss.Left,
284		header,
285		"",
286		lipgloss.JoinHorizontal(
287			lipgloss.Left,
288			promptTag,
289			" ",
290			promptText,
291		),
292	)
293
294	// Build tree with nested tool calls.
295	childTools := tree.Root(header)
296
297	for _, nestedTool := range r.fetch.nestedTools {
298		childView := nestedTool.Render(remainingWidth)
299		childTools.Child(childView)
300	}
301
302	// Build parts.
303	var parts []string
304	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
305
306	// Show animation if still running.
307	if !opts.HasResult() && !opts.IsCanceled() {
308		parts = append(parts, "", opts.Anim.Render())
309	}
310
311	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
312
313	// Add body content when completed.
314	if opts.HasResult() && opts.Result.Content != "" {
315		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
316		return joinToolParts(result, body)
317	}
318
319	return result
320}