agent.go 38 KB

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