agent.go 38 KB

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