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.
 80func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
 81	a.nestedTools = tools
 82	a.clearCache()
 83}
 84
 85// AddNestedTool adds a nested tool.
 86func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
 87	// Mark nested tools as simple (compact) rendering.
 88	if s, ok := tool.(Compactable); ok {
 89		s.SetCompact(true)
 90	}
 91	a.nestedTools = append(a.nestedTools, tool)
 92	a.clearCache()
 93}
 94
 95// AgentToolRenderContext renders agent tool messages.
 96type AgentToolRenderContext struct {
 97	agent *AgentToolMessageItem
 98}
 99
100// RenderTool implements the [ToolRenderer] interface.
101func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
102	cappedWidth := cappedMessageWidth(width)
103	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
104		return pendingTool(sty, "Agent", opts.Anim)
105	}
106
107	var params agent.AgentParams
108	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
109
110	prompt := params.Prompt
111	prompt = strings.ReplaceAll(prompt, "\n", " ")
112
113	header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
114	if opts.Compact {
115		return header
116	}
117
118	// Build the task tag and prompt.
119	taskTag := sty.Tool.AgentTaskTag.Render("Task")
120	taskTagWidth := lipgloss.Width(taskTag)
121
122	// Calculate remaining width for prompt.
123	remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
124
125	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
126
127	header = lipgloss.JoinVertical(
128		lipgloss.Left,
129		header,
130		"",
131		lipgloss.JoinHorizontal(
132			lipgloss.Left,
133			taskTag,
134			" ",
135			promptText,
136		),
137	)
138
139	// Build tree with nested tool calls.
140	childTools := tree.Root(header)
141
142	for _, nestedTool := range r.agent.nestedTools {
143		childView := nestedTool.Render(remainingWidth)
144		childTools.Child(childView)
145	}
146
147	// Build parts.
148	var parts []string
149	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
150
151	// Show animation if still running.
152	if !opts.HasResult() && !opts.IsCanceled() {
153		parts = append(parts, "", opts.Anim.Render())
154	}
155
156	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
157
158	// Add body content when completed.
159	if opts.HasResult() && opts.Result.Content != "" {
160		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
161		return joinToolParts(result, body)
162	}
163
164	return result
165}
166
167// -----------------------------------------------------------------------------
168// Agentic Fetch Tool
169// -----------------------------------------------------------------------------
170
171// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
172type AgenticFetchToolMessageItem struct {
173	*baseToolMessageItem
174
175	nestedTools []ToolMessageItem
176}
177
178var (
179	_ ToolMessageItem     = (*AgenticFetchToolMessageItem)(nil)
180	_ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
181)
182
183// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
184func NewAgenticFetchToolMessageItem(
185	sty *styles.Styles,
186	toolCall message.ToolCall,
187	result *message.ToolResult,
188	canceled bool,
189) *AgenticFetchToolMessageItem {
190	t := &AgenticFetchToolMessageItem{}
191	t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
192	// For the agentic fetch tool we keep spinning until the tool call is finished.
193	t.spinningFunc = func(state SpinningState) bool {
194		return !state.HasResult() && !state.IsCanceled()
195	}
196	return t
197}
198
199// NestedTools returns the nested tools.
200func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201	return a.nestedTools
202}
203
204// SetNestedTools sets the nested tools.
205func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206	a.nestedTools = tools
207	a.clearCache()
208}
209
210// AddNestedTool adds a nested tool.
211func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
212	// Mark nested tools as simple (compact) rendering.
213	if s, ok := tool.(Compactable); ok {
214		s.SetCompact(true)
215	}
216	a.nestedTools = append(a.nestedTools, tool)
217	a.clearCache()
218}
219
220// AgenticFetchToolRenderContext renders agentic fetch tool messages.
221type AgenticFetchToolRenderContext struct {
222	fetch *AgenticFetchToolMessageItem
223}
224
225// agenticFetchParams matches tools.AgenticFetchParams.
226type agenticFetchParams struct {
227	URL    string `json:"url,omitempty"`
228	Prompt string `json:"prompt"`
229}
230
231// RenderTool implements the [ToolRenderer] interface.
232func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
233	cappedWidth := cappedMessageWidth(width)
234	if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
235		return pendingTool(sty, "Agentic Fetch", opts.Anim)
236	}
237
238	var params agenticFetchParams
239	_ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
240
241	prompt := params.Prompt
242	prompt = strings.ReplaceAll(prompt, "\n", " ")
243
244	// Build header with optional URL param.
245	toolParams := []string{}
246	if params.URL != "" {
247		toolParams = append(toolParams, params.URL)
248	}
249
250	header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
251	if opts.Compact {
252		return header
253	}
254
255	// Build the prompt tag.
256	promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
257	promptTagWidth := lipgloss.Width(promptTag)
258
259	// Calculate remaining width for prompt text.
260	remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
261
262	promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
263
264	header = lipgloss.JoinVertical(
265		lipgloss.Left,
266		header,
267		"",
268		lipgloss.JoinHorizontal(
269			lipgloss.Left,
270			promptTag,
271			" ",
272			promptText,
273		),
274	)
275
276	// Build tree with nested tool calls.
277	childTools := tree.Root(header)
278
279	for _, nestedTool := range r.fetch.nestedTools {
280		childView := nestedTool.Render(remainingWidth)
281		childTools.Child(childView)
282	}
283
284	// Build parts.
285	var parts []string
286	parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
287
288	// Show animation if still running.
289	if !opts.HasResult() && !opts.IsCanceled() {
290		parts = append(parts, "", opts.Anim.Render())
291	}
292
293	result := lipgloss.JoinVertical(lipgloss.Left, parts...)
294
295	// Add body content when completed.
296	if opts.HasResult() && opts.Result.Content != "" {
297		body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
298		return joinToolParts(result, body)
299	}
300
301	return result
302}