agent.go

   1// Package agent is the core orchestration layer for Crush AI agents.
   2//
   3// It provides session-based AI agent functionality for managing
   4// conversations, tool execution, and message handling. It coordinates
   5// interactions between language models, messages, sessions, and tools while
   6// handling features like automatic summarization, queuing, and token
   7// management.
   8package agent
   9
  10import (
  11	"cmp"
  12	"context"
  13	_ "embed"
  14	"encoding/base64"
  15	"errors"
  16	"fmt"
  17	"log/slog"
  18	"os"
  19	"path/filepath"
  20	"regexp"
  21	"strconv"
  22	"strings"
  23	"sync"
  24	"time"
  25
  26	"charm.land/fantasy"
  27	"charm.land/fantasy/providers/anthropic"
  28	"charm.land/fantasy/providers/bedrock"
  29	"charm.land/fantasy/providers/google"
  30	"charm.land/fantasy/providers/openai"
  31	"charm.land/fantasy/providers/openrouter"
  32	"charm.land/lipgloss/v2"
  33	"github.com/charmbracelet/catwalk/pkg/catwalk"
  34	"github.com/charmbracelet/crush/internal/agent/hyper"
  35	"github.com/charmbracelet/crush/internal/agent/tools"
  36	"github.com/charmbracelet/crush/internal/config"
  37	"github.com/charmbracelet/crush/internal/csync"
  38	"github.com/charmbracelet/crush/internal/message"
  39	"github.com/charmbracelet/crush/internal/permission"
  40	"github.com/charmbracelet/crush/internal/session"
  41	"github.com/charmbracelet/crush/internal/stringext"
  42	"github.com/charmbracelet/x/exp/charmtone"
  43)
  44
  45const (
  46	defaultSessionName = "Untitled Session"
  47
  48	// Constants for auto-summarization thresholds
  49	largeContextWindowThreshold = 200_000
  50	largeContextWindowBuffer    = 20_000
  51	smallContextWindowRatio     = 0.2
  52)
  53
  54//go:embed templates/title.md
  55var titlePrompt []byte
  56
  57//go:embed templates/summary.md
  58var summaryPrompt []byte
  59
  60// Used to remove <think> tags from generated titles.
  61var thinkTagRegex = regexp.MustCompile(`<think>.*?</think>`)
  62
  63type SessionAgentCall struct {
  64	SessionID        string
  65	Prompt           string
  66	ProviderOptions  fantasy.ProviderOptions
  67	Attachments      []message.Attachment
  68	MaxOutputTokens  int64
  69	Temperature      *float64
  70	TopP             *float64
  71	TopK             *int64
  72	FrequencyPenalty *float64
  73	PresencePenalty  *float64
  74}
  75
  76type SessionAgent interface {
  77	Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
  78	SetModels(large Model, small Model)
  79	SetTools(tools []fantasy.AgentTool)
  80	SetSystemPrompt(systemPrompt string)
  81	Cancel(sessionID string)
  82	CancelAll()
  83	IsSessionBusy(sessionID string) bool
  84	IsBusy() bool
  85	QueuedPrompts(sessionID string) int
  86	QueuedPromptsList(sessionID string) []string
  87	ClearQueue(sessionID string)
  88	Summarize(context.Context, string, fantasy.ProviderOptions) error
  89	Model() Model
  90}
  91
  92type Model struct {
  93	Model      fantasy.LanguageModel
  94	CatwalkCfg catwalk.Model
  95	ModelCfg   config.SelectedModel
  96}
  97
  98type sessionAgent struct {
  99	largeModel         *csync.Value[Model]
 100	smallModel         *csync.Value[Model]
 101	systemPromptPrefix *csync.Value[string]
 102	systemPrompt       *csync.Value[string]
 103	tools              *csync.Slice[fantasy.AgentTool]
 104
 105	isSubAgent           bool
 106	sessions             session.Service
 107	messages             message.Service
 108	disableAutoSummarize bool
 109	isYolo               bool
 110
 111	messageQueue   *csync.Map[string, []SessionAgentCall]
 112	activeRequests *csync.Map[string, context.CancelFunc]
 113}
 114
 115type SessionAgentOptions struct {
 116	LargeModel           Model
 117	SmallModel           Model
 118	SystemPromptPrefix   string
 119	SystemPrompt         string
 120	IsSubAgent           bool
 121	DisableAutoSummarize bool
 122	IsYolo               bool
 123	Sessions             session.Service
 124	Messages             message.Service
 125	Tools                []fantasy.AgentTool
 126}
 127
 128func NewSessionAgent(
 129	opts SessionAgentOptions,
 130) SessionAgent {
 131	return &sessionAgent{
 132		largeModel:           csync.NewValue(opts.LargeModel),
 133		smallModel:           csync.NewValue(opts.SmallModel),
 134		systemPromptPrefix:   csync.NewValue(opts.SystemPromptPrefix),
 135		systemPrompt:         csync.NewValue(opts.SystemPrompt),
 136		isSubAgent:           opts.IsSubAgent,
 137		sessions:             opts.Sessions,
 138		messages:             opts.Messages,
 139		disableAutoSummarize: opts.DisableAutoSummarize,
 140		tools:                csync.NewSliceFrom(opts.Tools),
 141		isYolo:               opts.IsYolo,
 142		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
 143		activeRequests:       csync.NewMap[string, context.CancelFunc](),
 144	}
 145}
 146
 147func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
 148	if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
 149		return nil, ErrEmptyPrompt
 150	}
 151	if call.SessionID == "" {
 152		return nil, ErrSessionMissing
 153	}
 154
 155	// Queue the message if busy
 156	if a.IsSessionBusy(call.SessionID) {
 157		existing, ok := a.messageQueue.Get(call.SessionID)
 158		if !ok {
 159			existing = []SessionAgentCall{}
 160		}
 161		existing = append(existing, call)
 162		a.messageQueue.Set(call.SessionID, existing)
 163		return nil, nil
 164	}
 165
 166	// Copy mutable fields under lock to avoid races with SetTools/SetModels.
 167	agentTools := a.tools.Copy()
 168	largeModel := a.largeModel.Get()
 169	systemPrompt := a.systemPrompt.Get()
 170	promptPrefix := a.systemPromptPrefix.Get()
 171
 172	if len(agentTools) > 0 {
 173		// Add Anthropic caching to the last tool.
 174		agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
 175	}
 176
 177	agent := fantasy.NewAgent(
 178		largeModel.Model,
 179		fantasy.WithSystemPrompt(systemPrompt),
 180		fantasy.WithTools(agentTools...),
 181	)
 182
 183	sessionLock := sync.Mutex{}
 184	currentSession, err := a.sessions.Get(ctx, call.SessionID)
 185	if err != nil {
 186		return nil, fmt.Errorf("failed to get session: %w", err)
 187	}
 188
 189	msgs, err := a.getSessionMessages(ctx, currentSession)
 190	if err != nil {
 191		return nil, fmt.Errorf("failed to get session messages: %w", err)
 192	}
 193
 194	var wg sync.WaitGroup
 195	// Generate title if first message.
 196	if len(msgs) == 0 {
 197		titleCtx := ctx // Copy to avoid race with ctx reassignment below.
 198		wg.Go(func() {
 199			a.generateTitle(titleCtx, call.SessionID, call.Prompt)
 200		})
 201	}
 202	defer wg.Wait()
 203
 204	// Add the user message to the session.
 205	_, err = a.createUserMessage(ctx, call)
 206	if err != nil {
 207		return nil, err
 208	}
 209
 210	// Add the session to the context.
 211	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
 212
 213	genCtx, cancel := context.WithCancel(ctx)
 214	a.activeRequests.Set(call.SessionID, cancel)
 215
 216	defer cancel()
 217	defer a.activeRequests.Del(call.SessionID)
 218
 219	history, files := a.preparePrompt(msgs, call.Attachments...)
 220
 221	startTime := time.Now()
 222	a.eventPromptSent(call.SessionID)
 223
 224	var currentAssistant *message.Message
 225	var shouldSummarize bool
 226	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
 227		Prompt:           message.PromptWithTextAttachments(call.Prompt, call.Attachments),
 228		Files:            files,
 229		Messages:         history,
 230		ProviderOptions:  call.ProviderOptions,
 231		MaxOutputTokens:  &call.MaxOutputTokens,
 232		TopP:             call.TopP,
 233		Temperature:      call.Temperature,
 234		PresencePenalty:  call.PresencePenalty,
 235		TopK:             call.TopK,
 236		FrequencyPenalty: call.FrequencyPenalty,
 237		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 238			prepared.Messages = options.Messages
 239			for i := range prepared.Messages {
 240				prepared.Messages[i].ProviderOptions = nil
 241			}
 242
 243			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
 244			a.messageQueue.Del(call.SessionID)
 245			for _, queued := range queuedCalls {
 246				userMessage, createErr := a.createUserMessage(callContext, queued)
 247				if createErr != nil {
 248					return callContext, prepared, createErr
 249				}
 250				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
 251			}
 252
 253			prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
 254
 255			lastSystemRoleInx := 0
 256			systemMessageUpdated := false
 257			for i, msg := range prepared.Messages {
 258				// Only add cache control to the last message.
 259				if msg.Role == fantasy.MessageRoleSystem {
 260					lastSystemRoleInx = i
 261				} else if !systemMessageUpdated {
 262					prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
 263					systemMessageUpdated = true
 264				}
 265				// Than add cache control to the last 2 messages.
 266				if i > len(prepared.Messages)-3 {
 267					prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
 268				}
 269			}
 270
 271			if promptPrefix != "" {
 272				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
 273			}
 274
 275			var assistantMsg message.Message
 276			assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
 277				Role:     message.Assistant,
 278				Parts:    []message.ContentPart{},
 279				Model:    largeModel.ModelCfg.Model,
 280				Provider: largeModel.ModelCfg.Provider,
 281			})
 282			if err != nil {
 283				return callContext, prepared, err
 284			}
 285			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
 286			callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
 287			callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
 288			currentAssistant = &assistantMsg
 289			return callContext, prepared, err
 290		},
 291		OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
 292			currentAssistant.AppendReasoningContent(reasoning.Text)
 293			return a.messages.Update(genCtx, *currentAssistant)
 294		},
 295		OnReasoningDelta: func(id string, text string) error {
 296			currentAssistant.AppendReasoningContent(text)
 297			return a.messages.Update(genCtx, *currentAssistant)
 298		},
 299		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
 300			// handle anthropic signature
 301			if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
 302				if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
 303					currentAssistant.AppendReasoningSignature(reasoning.Signature)
 304				}
 305			}
 306			if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
 307				if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
 308					currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
 309				}
 310			}
 311			if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
 312				if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
 313					currentAssistant.SetReasoningResponsesData(reasoning)
 314				}
 315			}
 316			currentAssistant.FinishThinking()
 317			return a.messages.Update(genCtx, *currentAssistant)
 318		},
 319		OnTextDelta: func(id string, text string) error {
 320			// Strip leading newline from initial text content. This is is
 321			// particularly important in non-interactive mode where leading
 322			// newlines are very visible.
 323			if len(currentAssistant.Parts) == 0 {
 324				text = strings.TrimPrefix(text, "\n")
 325			}
 326
 327			currentAssistant.AppendContent(text)
 328			return a.messages.Update(genCtx, *currentAssistant)
 329		},
 330		OnToolInputStart: func(id string, toolName string) error {
 331			toolCall := message.ToolCall{
 332				ID:               id,
 333				Name:             toolName,
 334				ProviderExecuted: false,
 335				Finished:         false,
 336			}
 337			currentAssistant.AddToolCall(toolCall)
 338			return a.messages.Update(genCtx, *currentAssistant)
 339		},
 340		OnRetry: func(err *fantasy.ProviderError, delay time.Duration) {
 341			// TODO: implement
 342		},
 343		OnToolCall: func(tc fantasy.ToolCallContent) error {
 344			toolCall := message.ToolCall{
 345				ID:               tc.ToolCallID,
 346				Name:             tc.ToolName,
 347				Input:            tc.Input,
 348				ProviderExecuted: false,
 349				Finished:         true,
 350			}
 351			currentAssistant.AddToolCall(toolCall)
 352			return a.messages.Update(genCtx, *currentAssistant)
 353		},
 354		OnToolResult: func(result fantasy.ToolResultContent) error {
 355			toolResult := a.convertToToolResult(result)
 356			_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
 357				Role: message.Tool,
 358				Parts: []message.ContentPart{
 359					toolResult,
 360				},
 361			})
 362			return createMsgErr
 363		},
 364		OnStepFinish: func(stepResult fantasy.StepResult) error {
 365			finishReason := message.FinishReasonUnknown
 366			switch stepResult.FinishReason {
 367			case fantasy.FinishReasonLength:
 368				finishReason = message.FinishReasonMaxTokens
 369			case fantasy.FinishReasonStop:
 370				finishReason = message.FinishReasonEndTurn
 371			case fantasy.FinishReasonToolCalls:
 372				finishReason = message.FinishReasonToolUse
 373			}
 374			currentAssistant.AddFinish(finishReason, "", "")
 375			sessionLock.Lock()
 376			updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID)
 377			if getSessionErr != nil {
 378				sessionLock.Unlock()
 379				return getSessionErr
 380			}
 381			a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
 382			_, sessionErr := a.sessions.Save(genCtx, updatedSession)
 383			sessionLock.Unlock()
 384			if sessionErr != nil {
 385				return sessionErr
 386			}
 387			return a.messages.Update(genCtx, *currentAssistant)
 388		},
 389		StopWhen: []fantasy.StopCondition{
 390			func(_ []fantasy.StepResult) bool {
 391				cw := int64(largeModel.CatwalkCfg.ContextWindow)
 392				tokens := currentSession.CompletionTokens + currentSession.PromptTokens
 393				remaining := cw - tokens
 394				var threshold int64
 395				if cw > largeContextWindowThreshold {
 396					threshold = largeContextWindowBuffer
 397				} else {
 398					threshold = int64(float64(cw) * smallContextWindowRatio)
 399				}
 400				if (remaining <= threshold) && !a.disableAutoSummarize {
 401					shouldSummarize = true
 402					return true
 403				}
 404				return false
 405			},
 406		},
 407	})
 408
 409	a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
 410
 411	if err != nil {
 412		isCancelErr := errors.Is(err, context.Canceled)
 413		isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 414		if currentAssistant == nil {
 415			return result, err
 416		}
 417		// Ensure we finish thinking on error to close the reasoning state.
 418		currentAssistant.FinishThinking()
 419		toolCalls := currentAssistant.ToolCalls()
 420		// INFO: we use the parent context here because the genCtx has been cancelled.
 421		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
 422		if createErr != nil {
 423			return nil, createErr
 424		}
 425		for _, tc := range toolCalls {
 426			if !tc.Finished {
 427				tc.Finished = true
 428				tc.Input = "{}"
 429				currentAssistant.AddToolCall(tc)
 430				updateErr := a.messages.Update(ctx, *currentAssistant)
 431				if updateErr != nil {
 432					return nil, updateErr
 433				}
 434			}
 435
 436			found := false
 437			for _, msg := range msgs {
 438				if msg.Role == message.Tool {
 439					for _, tr := range msg.ToolResults() {
 440						if tr.ToolCallID == tc.ID {
 441							found = true
 442							break
 443						}
 444					}
 445				}
 446				if found {
 447					break
 448				}
 449			}
 450			if found {
 451				continue
 452			}
 453			content := "There was an error while executing the tool"
 454			if isCancelErr {
 455				content = "Tool execution canceled by user"
 456			} else if isPermissionErr {
 457				content = "User denied permission"
 458			}
 459			toolResult := message.ToolResult{
 460				ToolCallID: tc.ID,
 461				Name:       tc.Name,
 462				Content:    content,
 463				IsError:    true,
 464			}
 465			_, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
 466				Role: message.Tool,
 467				Parts: []message.ContentPart{
 468					toolResult,
 469				},
 470			})
 471			if createErr != nil {
 472				return nil, createErr
 473			}
 474		}
 475		var fantasyErr *fantasy.Error
 476		var providerErr *fantasy.ProviderError
 477		const defaultTitle = "Provider Error"
 478		linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
 479		if isCancelErr {
 480			currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
 481		} else if isPermissionErr {
 482			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
 483		} else if errors.Is(err, hyper.ErrNoCredits) {
 484			url := hyper.BaseURL()
 485			link := linkStyle.Hyperlink(url, "id=hyper").Render(url)
 486			currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
 487		} else if errors.As(err, &providerErr) {
 488			if providerErr.Message == "The requested model is not supported." {
 489				url := "https://github.com/settings/copilot/features"
 490				link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
 491				currentAssistant.AddFinish(
 492					message.FinishReasonError,
 493					"Copilot model not enabled",
 494					fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
 495				)
 496			} else {
 497				currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
 498			}
 499		} else if errors.As(err, &fantasyErr) {
 500			currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
 501		} else {
 502			currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
 503		}
 504		// Note: we use the parent context here because the genCtx has been
 505		// cancelled.
 506		updateErr := a.messages.Update(ctx, *currentAssistant)
 507		if updateErr != nil {
 508			return nil, updateErr
 509		}
 510		return nil, err
 511	}
 512
 513	if shouldSummarize {
 514		a.activeRequests.Del(call.SessionID)
 515		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
 516			return nil, summarizeErr
 517		}
 518		// If the agent wasn't done...
 519		if len(currentAssistant.ToolCalls()) > 0 {
 520			existing, ok := a.messageQueue.Get(call.SessionID)
 521			if !ok {
 522				existing = []SessionAgentCall{}
 523			}
 524			call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
 525			existing = append(existing, call)
 526			a.messageQueue.Set(call.SessionID, existing)
 527		}
 528	}
 529
 530	// Release active request before processing queued messages.
 531	a.activeRequests.Del(call.SessionID)
 532	cancel()
 533
 534	queuedMessages, ok := a.messageQueue.Get(call.SessionID)
 535	if !ok || len(queuedMessages) == 0 {
 536		return result, err
 537	}
 538	// There are queued messages restart the loop.
 539	firstQueuedMessage := queuedMessages[0]
 540	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
 541	return a.Run(ctx, firstQueuedMessage)
 542}
 543
 544func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
 545	if a.IsSessionBusy(sessionID) {
 546		return ErrSessionBusy
 547	}
 548
 549	// Copy mutable fields under lock to avoid races with SetModels.
 550	largeModel := a.largeModel.Get()
 551	systemPromptPrefix := a.systemPromptPrefix.Get()
 552
 553	currentSession, err := a.sessions.Get(ctx, sessionID)
 554	if err != nil {
 555		return fmt.Errorf("failed to get session: %w", err)
 556	}
 557	msgs, err := a.getSessionMessages(ctx, currentSession)
 558	if err != nil {
 559		return err
 560	}
 561	if len(msgs) == 0 {
 562		// Nothing to summarize.
 563		return nil
 564	}
 565
 566	// Save transcript for later search via memory_search tool.
 567	if err := a.saveTranscript(ctx, sessionID); err != nil {
 568		slog.Warn("failed to save transcript", "error", err)
 569	}
 570
 571	aiMsgs, _ := a.preparePrompt(msgs)
 572
 573	genCtx, cancel := context.WithCancel(ctx)
 574	a.activeRequests.Set(sessionID, cancel)
 575	defer a.activeRequests.Del(sessionID)
 576	defer cancel()
 577
 578	agent := fantasy.NewAgent(largeModel.Model,
 579		fantasy.WithSystemPrompt(string(summaryPrompt)),
 580	)
 581	summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
 582		Role:             message.Assistant,
 583		Model:            largeModel.Model.Model(),
 584		Provider:         largeModel.Model.Provider(),
 585		IsSummaryMessage: true,
 586	})
 587	if err != nil {
 588		return err
 589	}
 590
 591	summaryPromptText := buildSummaryPrompt(sessionID, currentSession.Todos)
 592
 593	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
 594		Prompt:          summaryPromptText,
 595		Messages:        aiMsgs,
 596		ProviderOptions: opts,
 597		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 598			prepared.Messages = options.Messages
 599			if systemPromptPrefix != "" {
 600				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
 601			}
 602			return callContext, prepared, nil
 603		},
 604		OnReasoningDelta: func(id string, text string) error {
 605			summaryMessage.AppendReasoningContent(text)
 606			return a.messages.Update(genCtx, summaryMessage)
 607		},
 608		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
 609			// Handle anthropic signature.
 610			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
 611				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
 612					summaryMessage.AppendReasoningSignature(signature.Signature)
 613				}
 614			}
 615			summaryMessage.FinishThinking()
 616			return a.messages.Update(genCtx, summaryMessage)
 617		},
 618		OnTextDelta: func(id, text string) error {
 619			summaryMessage.AppendContent(text)
 620			return a.messages.Update(genCtx, summaryMessage)
 621		},
 622	})
 623	if err != nil {
 624		isCancelErr := errors.Is(err, context.Canceled)
 625		if isCancelErr {
 626			// User cancelled summarize we need to remove the summary message.
 627			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
 628			return deleteErr
 629		}
 630		return err
 631	}
 632
 633	summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
 634	err = a.messages.Update(genCtx, summaryMessage)
 635	if err != nil {
 636		return err
 637	}
 638
 639	var openrouterCost *float64
 640	for _, step := range resp.Steps {
 641		stepCost := a.openrouterCost(step.ProviderMetadata)
 642		if stepCost != nil {
 643			newCost := *stepCost
 644			if openrouterCost != nil {
 645				newCost += *openrouterCost
 646			}
 647			openrouterCost = &newCost
 648		}
 649	}
 650
 651	a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
 652
 653	// Just in case, get just the last usage info.
 654	usage := resp.Response.Usage
 655	currentSession.SummaryMessageID = summaryMessage.ID
 656	currentSession.CompletionTokens = usage.OutputTokens
 657	currentSession.PromptTokens = 0
 658	_, err = a.sessions.Save(genCtx, currentSession)
 659	return err
 660}
 661
 662func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
 663	if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
 664		return fantasy.ProviderOptions{}
 665	}
 666	return fantasy.ProviderOptions{
 667		anthropic.Name: &anthropic.ProviderCacheControlOptions{
 668			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 669		},
 670		bedrock.Name: &anthropic.ProviderCacheControlOptions{
 671			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 672		},
 673	}
 674}
 675
 676func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
 677	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
 678	var attachmentParts []message.ContentPart
 679	for _, attachment := range call.Attachments {
 680		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 681	}
 682	parts = append(parts, attachmentParts...)
 683	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
 684		Role:  message.User,
 685		Parts: parts,
 686	})
 687	if err != nil {
 688		return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
 689	}
 690	return msg, nil
 691}
 692
 693func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
 694	var history []fantasy.Message
 695	hasSummary := false
 696	for _, msg := range msgs {
 697		if msg.IsSummaryMessage {
 698			hasSummary = true
 699			break
 700		}
 701	}
 702	if !a.isSubAgent {
 703		history = append(history, fantasy.NewUserMessage(
 704			fmt.Sprintf("<system_reminder>%s</system_reminder>",
 705				`This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
 706If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
 707If not, please feel free to ignore. Again do not mention this message to the user.`,
 708			),
 709		))
 710		if hasSummary {
 711			history = append(history, fantasy.NewUserMessage(
 712				fmt.Sprintf("<system_reminder>%s</system_reminder>",
 713					`This session was summarized. If you need specific details from before the summary (commands, code, file paths, errors, decisions), use the "memory_search" tool to search the full transcript instead of guessing.`,
 714				),
 715			))
 716		}
 717	}
 718	for _, m := range msgs {
 719		if len(m.Parts) == 0 {
 720			continue
 721		}
 722		// Assistant message without content or tool calls (cancelled before it
 723		// returned anything).
 724		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
 725			continue
 726		}
 727		history = append(history, m.ToAIMessage()...)
 728	}
 729
 730	var files []fantasy.FilePart
 731	for _, attachment := range attachments {
 732		if attachment.IsText() {
 733			continue
 734		}
 735		files = append(files, fantasy.FilePart{
 736			Filename:  attachment.FileName,
 737			Data:      attachment.Content,
 738			MediaType: attachment.MimeType,
 739		})
 740	}
 741
 742	return history, files
 743}
 744
 745func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
 746	msgs, err := a.messages.List(ctx, session.ID)
 747	if err != nil {
 748		return nil, fmt.Errorf("failed to list messages: %w", err)
 749	}
 750
 751	if session.SummaryMessageID != "" {
 752		summaryMsgIndex := -1
 753		for i, msg := range msgs {
 754			if msg.ID == session.SummaryMessageID {
 755				summaryMsgIndex = i
 756				break
 757			}
 758		}
 759		if summaryMsgIndex != -1 {
 760			msgs = msgs[summaryMsgIndex:]
 761			msgs[0].Role = message.User
 762		}
 763	}
 764	return msgs, nil
 765}
 766
 767// generateTitle generates a session titled based on the initial prompt.
 768func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
 769	if userPrompt == "" {
 770		return
 771	}
 772
 773	smallModel := a.smallModel.Get()
 774	largeModel := a.largeModel.Get()
 775	systemPromptPrefix := a.systemPromptPrefix.Get()
 776
 777	var maxOutputTokens int64 = 40
 778	if smallModel.CatwalkCfg.CanReason {
 779		maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
 780	}
 781
 782	newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
 783		return fantasy.NewAgent(m,
 784			fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
 785			fantasy.WithMaxOutputTokens(tok),
 786		)
 787	}
 788
 789	streamCall := fantasy.AgentStreamCall{
 790		Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
 791		PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 792			prepared.Messages = opts.Messages
 793			if systemPromptPrefix != "" {
 794				prepared.Messages = append([]fantasy.Message{
 795					fantasy.NewSystemMessage(systemPromptPrefix),
 796				}, prepared.Messages...)
 797			}
 798			return callCtx, prepared, nil
 799		},
 800	}
 801
 802	// Use the small model to generate the title.
 803	model := smallModel
 804	agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
 805	resp, err := agent.Stream(ctx, streamCall)
 806	if err == nil {
 807		// We successfully generated a title with the small model.
 808		slog.Info("generated title with small model")
 809	} else {
 810		// It didn't work. Let's try with the big model.
 811		slog.Error("error generating title with small model; trying big model", "err", err)
 812		model = largeModel
 813		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 814		resp, err = agent.Stream(ctx, streamCall)
 815		if err == nil {
 816			slog.Info("generated title with large model")
 817		} else {
 818			// Welp, the large model didn't work either. Use the default
 819			// session name and return.
 820			slog.Error("error generating title with large model", "err", err)
 821			saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 822			if saveErr != nil {
 823				slog.Error("failed to save session title and usage", "error", saveErr)
 824			}
 825			return
 826		}
 827	}
 828
 829	if resp == nil {
 830		// Actually, we didn't get a response so we can't. Use the default
 831		// session name and return.
 832		slog.Error("response is nil; can't generate title")
 833		saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 834		if saveErr != nil {
 835			slog.Error("failed to save session title and usage", "error", saveErr)
 836		}
 837		return
 838	}
 839
 840	// Clean up title.
 841	var title string
 842	title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
 843
 844	// Remove thinking tags if present.
 845	title = thinkTagRegex.ReplaceAllString(title, "")
 846
 847	title = strings.TrimSpace(title)
 848	if title == "" {
 849		slog.Warn("empty title; using fallback")
 850		title = defaultSessionName
 851	}
 852
 853	// Calculate usage and cost.
 854	var openrouterCost *float64
 855	for _, step := range resp.Steps {
 856		stepCost := a.openrouterCost(step.ProviderMetadata)
 857		if stepCost != nil {
 858			newCost := *stepCost
 859			if openrouterCost != nil {
 860				newCost += *openrouterCost
 861			}
 862			openrouterCost = &newCost
 863		}
 864	}
 865
 866	modelConfig := model.CatwalkCfg
 867	cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
 868		modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
 869		modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
 870		modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
 871
 872	// Use override cost if available (e.g., from OpenRouter).
 873	if openrouterCost != nil {
 874		cost = *openrouterCost
 875	}
 876
 877	promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
 878	completionTokens := resp.TotalUsage.OutputTokens + resp.TotalUsage.CacheReadTokens
 879
 880	// Atomically update only title and usage fields to avoid overriding other
 881	// concurrent session updates.
 882	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
 883	if saveErr != nil {
 884		slog.Error("failed to save session title and usage", "error", saveErr)
 885		return
 886	}
 887}
 888
 889func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
 890	openrouterMetadata, ok := metadata[openrouter.Name]
 891	if !ok {
 892		return nil
 893	}
 894
 895	opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
 896	if !ok {
 897		return nil
 898	}
 899	return &opts.Usage.Cost
 900}
 901
 902func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
 903	modelConfig := model.CatwalkCfg
 904	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
 905		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
 906		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
 907		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
 908
 909	a.eventTokensUsed(session.ID, model, usage, cost)
 910
 911	if overrideCost != nil {
 912		session.Cost += *overrideCost
 913	} else {
 914		session.Cost += cost
 915	}
 916
 917	session.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
 918	session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
 919}
 920
 921func (a *sessionAgent) Cancel(sessionID string) {
 922	// Cancel regular requests. Don't use Take() here - we need the entry to
 923	// remain in activeRequests so IsBusy() returns true until the goroutine
 924	// fully completes (including error handling that may access the DB).
 925	// The defer in processRequest will clean up the entry.
 926	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
 927		slog.Info("Request cancellation initiated", "session_id", sessionID)
 928		cancel()
 929	}
 930
 931	// Also check for summarize requests.
 932	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
 933		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
 934		cancel()
 935	}
 936
 937	if a.QueuedPrompts(sessionID) > 0 {
 938		slog.Info("Clearing queued prompts", "session_id", sessionID)
 939		a.messageQueue.Del(sessionID)
 940	}
 941}
 942
 943func (a *sessionAgent) ClearQueue(sessionID string) {
 944	if a.QueuedPrompts(sessionID) > 0 {
 945		slog.Info("Clearing queued prompts", "session_id", sessionID)
 946		a.messageQueue.Del(sessionID)
 947	}
 948}
 949
 950func (a *sessionAgent) CancelAll() {
 951	if !a.IsBusy() {
 952		return
 953	}
 954	for key := range a.activeRequests.Seq2() {
 955		a.Cancel(key) // key is sessionID
 956	}
 957
 958	timeout := time.After(5 * time.Second)
 959	for a.IsBusy() {
 960		select {
 961		case <-timeout:
 962			return
 963		default:
 964			time.Sleep(200 * time.Millisecond)
 965		}
 966	}
 967}
 968
 969func (a *sessionAgent) IsBusy() bool {
 970	var busy bool
 971	for cancelFunc := range a.activeRequests.Seq() {
 972		if cancelFunc != nil {
 973			busy = true
 974			break
 975		}
 976	}
 977	return busy
 978}
 979
 980func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
 981	_, busy := a.activeRequests.Get(sessionID)
 982	return busy
 983}
 984
 985func (a *sessionAgent) QueuedPrompts(sessionID string) int {
 986	l, ok := a.messageQueue.Get(sessionID)
 987	if !ok {
 988		return 0
 989	}
 990	return len(l)
 991}
 992
 993func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
 994	l, ok := a.messageQueue.Get(sessionID)
 995	if !ok {
 996		return nil
 997	}
 998	prompts := make([]string, len(l))
 999	for i, call := range l {
1000		prompts[i] = call.Prompt
1001	}
1002	return prompts
1003}
1004
1005func (a *sessionAgent) SetModels(large Model, small Model) {
1006	a.largeModel.Set(large)
1007	a.smallModel.Set(small)
1008}
1009
1010func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
1011	a.tools.SetSlice(tools)
1012}
1013
1014func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
1015	a.systemPrompt.Set(systemPrompt)
1016}
1017
1018func (a *sessionAgent) Model() Model {
1019	return a.largeModel.Get()
1020}
1021
1022// convertToToolResult converts a fantasy tool result to a message tool result.
1023func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
1024	baseResult := message.ToolResult{
1025		ToolCallID: result.ToolCallID,
1026		Name:       result.ToolName,
1027		Metadata:   result.ClientMetadata,
1028	}
1029
1030	switch result.Result.GetType() {
1031	case fantasy.ToolResultContentTypeText:
1032		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
1033			baseResult.Content = r.Text
1034		}
1035	case fantasy.ToolResultContentTypeError:
1036		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
1037			baseResult.Content = r.Error.Error()
1038			baseResult.IsError = true
1039		}
1040	case fantasy.ToolResultContentTypeMedia:
1041		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
1042			content := r.Text
1043			if content == "" {
1044				content = fmt.Sprintf("Loaded %s content", r.MediaType)
1045			}
1046			baseResult.Content = content
1047			baseResult.Data = r.Data
1048			baseResult.MIMEType = r.MediaType
1049		}
1050	}
1051
1052	return baseResult
1053}
1054
1055// workaroundProviderMediaLimitations converts media content in tool results to
1056// user messages for providers that don't natively support images in tool results.
1057//
1058// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
1059// don't support sending images/media in tool result messages - they only accept
1060// text in tool results. However, they DO support images in user messages.
1061//
1062// If we send media in tool results to these providers, the API returns an error.
1063//
1064// Solution: For these providers, we:
1065//  1. Replace the media in the tool result with a text placeholder
1066//  2. Inject a user message immediately after with the image as a file attachment
1067//  3. This maintains the tool execution flow while working around API limitations
1068//
1069// Anthropic and Bedrock support images natively in tool results, so we skip
1070// this workaround for them.
1071//
1072// Example transformation:
1073//
1074//	BEFORE: [tool result: image data]
1075//	AFTER:  [tool result: "Image loaded - see attached"], [user: image attachment]
1076func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1077	providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1078		largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1079
1080	if providerSupportsMedia {
1081		return messages
1082	}
1083
1084	convertedMessages := make([]fantasy.Message, 0, len(messages))
1085
1086	for _, msg := range messages {
1087		if msg.Role != fantasy.MessageRoleTool {
1088			convertedMessages = append(convertedMessages, msg)
1089			continue
1090		}
1091
1092		textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
1093		var mediaFiles []fantasy.FilePart
1094
1095		for _, part := range msg.Content {
1096			toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1097			if !ok {
1098				textParts = append(textParts, part)
1099				continue
1100			}
1101
1102			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
1103				decoded, err := base64.StdEncoding.DecodeString(media.Data)
1104				if err != nil {
1105					slog.Warn("failed to decode media data", "error", err)
1106					textParts = append(textParts, part)
1107					continue
1108				}
1109
1110				mediaFiles = append(mediaFiles, fantasy.FilePart{
1111					Data:      decoded,
1112					MediaType: media.MediaType,
1113					Filename:  fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
1114				})
1115
1116				textParts = append(textParts, fantasy.ToolResultPart{
1117					ToolCallID: toolResult.ToolCallID,
1118					Output: fantasy.ToolResultOutputContentText{
1119						Text: "[Image/media content loaded - see attached file]",
1120					},
1121					ProviderOptions: toolResult.ProviderOptions,
1122				})
1123			} else {
1124				textParts = append(textParts, part)
1125			}
1126		}
1127
1128		convertedMessages = append(convertedMessages, fantasy.Message{
1129			Role:    fantasy.MessageRoleTool,
1130			Content: textParts,
1131		})
1132
1133		if len(mediaFiles) > 0 {
1134			convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
1135				"Here is the media content from the tool result:",
1136				mediaFiles...,
1137			))
1138		}
1139	}
1140
1141	return convertedMessages
1142}
1143
1144// buildSummaryPrompt constructs the prompt text for session summarization.
1145func buildSummaryPrompt(sessionID string, todos []session.Todo) string {
1146	var sb strings.Builder
1147	sb.WriteString("Provide a detailed summary of our conversation above.")
1148
1149	// Include transcript path for memory search.
1150	transcriptPath := TranscriptPath(sessionID)
1151	sb.WriteString("\n\n## Session Transcript\n\n")
1152	sb.WriteString(fmt.Sprintf("The full conversation transcript has been saved to: `%s`\n", transcriptPath))
1153	sb.WriteString("The resuming assistant can use the `memory_search` tool to search this transcript for specific details from the conversation.\n")
1154
1155	if len(todos) > 0 {
1156		sb.WriteString("\n\n## Current Todo List\n\n")
1157		for _, t := range todos {
1158			fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
1159		}
1160		sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
1161		sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
1162	}
1163	return sb.String()
1164}
1165
1166// serializeTranscript converts a slice of messages to a searchable markdown
1167// transcript format. The transcript includes user messages, assistant
1168// responses, tool calls, tool results, and reasoning content.
1169func serializeTranscript(msgs []message.Message) string {
1170	var sb strings.Builder
1171	sb.WriteString("# Session Transcript\n\n")
1172
1173	for _, msg := range msgs {
1174		roleHeader := "Message"
1175		switch msg.Role {
1176		case message.User:
1177			roleHeader = "User"
1178		case message.Assistant:
1179			roleHeader = "Assistant"
1180		case message.Tool:
1181			roleHeader = "Tool Results"
1182		}
1183		sb.WriteString(fmt.Sprintf("## %s\n\n", roleHeader))
1184
1185		switch msg.Role {
1186		case message.User:
1187			if text := msg.Content().Text; text != "" {
1188				sb.WriteString("### Content\n\n")
1189				sb.WriteString(text)
1190				sb.WriteString("\n\n")
1191			}
1192			// Include binary content paths.
1193			attachments := msg.BinaryContent()
1194			if len(attachments) > 0 {
1195				sb.WriteString("### Attachments\n\n")
1196				for _, bc := range attachments {
1197					sb.WriteString(fmt.Sprintf("- %s (%s)\n", bc.Path, bc.MIMEType))
1198				}
1199				sb.WriteString("\n")
1200			}
1201
1202		case message.Assistant:
1203			if msg.Model != "" {
1204				sb.WriteString(fmt.Sprintf("**Model:** %s (%s)\n", msg.Model, msg.Provider))
1205			}
1206			sb.WriteString("\n")
1207
1208			// Reasoning content.
1209			if reasoning := msg.ReasoningContent(); reasoning.Thinking != "" {
1210				sb.WriteString("### Reasoning\n\n")
1211				sb.WriteString("<thinking>\n")
1212				sb.WriteString(reasoning.Thinking)
1213				sb.WriteString("\n</thinking>\n\n")
1214			}
1215
1216			// Text content.
1217			if text := msg.Content().Text; text != "" {
1218				sb.WriteString("### Response\n\n")
1219				sb.WriteString(text)
1220				sb.WriteString("\n\n")
1221			}
1222
1223			// Tool calls.
1224			toolCalls := msg.ToolCalls()
1225			if len(toolCalls) > 0 {
1226				sb.WriteString("### Tool Calls\n\n")
1227				for _, tc := range toolCalls {
1228					sb.WriteString("#### Tool Call\n\n")
1229					sb.WriteString(fmt.Sprintf("**Tool:** `%s`\n\n", tc.Name))
1230					sb.WriteString("**Input:**\n\n")
1231					sb.WriteString("```json\n")
1232					sb.WriteString(tc.Input)
1233					sb.WriteString("\n```\n\n")
1234				}
1235			}
1236
1237		case message.Tool:
1238			for _, tr := range msg.ToolResults() {
1239				sb.WriteString("#### Tool Result\n\n")
1240				sb.WriteString(fmt.Sprintf("**Tool:** `%s`\n", tr.Name))
1241				if tr.IsError {
1242					sb.WriteString("**Status:** Error\n")
1243				} else {
1244					sb.WriteString("**Status:** Success\n")
1245				}
1246				// Truncate very long tool results.
1247				content := tr.Content
1248				const maxToolResultLen = 10000
1249				if len(content) > maxToolResultLen {
1250					content = content[:maxToolResultLen] + "\n... (truncated)"
1251					sb.WriteString("**Output:** (truncated)\n\n")
1252				} else {
1253					sb.WriteString("**Output:**\n\n")
1254				}
1255				sb.WriteString("```\n")
1256				sb.WriteString(content)
1257				sb.WriteString("\n```\n\n")
1258			}
1259		}
1260
1261		sb.WriteString("---\n\n")
1262	}
1263
1264	return sb.String()
1265}
1266
1267// saveTranscript serializes messages to a markdown file for later search.
1268func (a *sessionAgent) saveTranscript(ctx context.Context, sessionID string) error {
1269	msgs, err := a.messages.List(ctx, sessionID)
1270	if err != nil {
1271		return fmt.Errorf("failed to list messages: %w", err)
1272	}
1273	cfg := config.Get()
1274	transcriptsDir := filepath.Join(cfg.Options.DataDirectory, "transcripts")
1275	if err := os.MkdirAll(transcriptsDir, 0o755); err != nil {
1276		return fmt.Errorf("failed to create transcripts directory: %w", err)
1277	}
1278
1279	transcriptPath := filepath.Join(transcriptsDir, sessionID+".md")
1280	transcript := serializeTranscript(msgs)
1281	if err := os.WriteFile(transcriptPath, []byte(transcript), 0o644); err != nil {
1282		return fmt.Errorf("failed to write transcript: %w", err)
1283	}
1284
1285	slog.Debug("saved transcript", "path", transcriptPath, "messages", len(msgs))
1286	return nil
1287}
1288
1289// TranscriptPath returns the path where a session's transcript would be saved.
1290func TranscriptPath(sessionID string) string {
1291	cfg := config.Get()
1292	return filepath.Join(cfg.Options.DataDirectory, "transcripts", sessionID+".md")
1293}