openai_gateway_grok_compact.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "strings"
  6. "github.com/google/uuid"
  7. )
  8. // This is build_compaction_prompt(None, false) from grok-build. Grok does not
  9. // expose an OpenAI-compatible /responses/compact endpoint, so compacting is a
  10. // normal Responses turn whose final user item asks the model to summarize.
  11. const grokCompactSummaryPrompt = `Your task is to produce a faithful, concise summary of the conversation so far so that a successor assistant can continue the work seamlessly after the earlier turns are discarded. The successor will see the user's original query plus this summary. Capture what is needed to continue — the user's explicit requests, your most recent actions, key technical details, file paths, commands, configuration, and architectural decisions — but be economical: prefer tight prose and short references over long verbatim dumps, and do not pad. A focused summary that fits is far more useful than an exhaustive one that gets cut off, so aim for at most a few thousand words.
  12. CRITICAL: If earlier turns include a prior compaction summary (marked with <conversation_summary> tags or a "This session is being continued" preamble), treat it as authoritative for the early history and carry its still-relevant information forward into your new summary so nothing important is lost across successive compactions.
  13. Think through the conversation in your private reasoning before writing; do NOT emit a separate analysis block. Output the final summary inside a single <summary>...</summary> block, organized into the following numbered sections. Include every section heading even if a section is empty (write "None" in that case):
  14. 1. Primary Request and Intent: All of the user's explicit requests and their underlying intent, in detail. Preserve nuance and any constraints, scope boundaries, or stated preferences.
  15. 2. Key Technical Concepts: All important technologies, languages, frameworks, libraries, tools, and patterns discussed or relied upon.
  16. 3. Files and Code Sections: Every file examined, created, or modified. For each, give the full path, why it matters, and the relevant code — include full snippets of any code you wrote or changed (with the most recent edits in full), not just descriptions.
  17. 4. Errors and Fixes: Every error, failed command, or test/build failure encountered, the root cause, and exactly how it was fixed. Note any fix that came from user feedback verbatim.
  18. 5. Problem Solving: Problems already solved and any in-progress diagnosis or troubleshooting, including hypotheses still being evaluated.
  19. 6. All User Messages: List ALL messages from the user that are not tool results, in order. These are critical for understanding intent and how it evolved. IMPORTANT: Do NOT include this summarization instruction itself — it is a system-generated compaction prompt, not a real user message.
  20. 7. Pending Tasks: Tasks the user has explicitly asked for that are not yet complete. Do not invent tasks the user never requested.
  21. 8. Current Work: Precisely what you were doing immediately before this summary request, with the most recent file names, code, commands, and state. Be specific enough that work can resume mid-stream.
  22. 9. Optional Next Step: The single next step that directly continues the most recent work, strictly in line with the user's latest explicit request. If the prior task was finished, only propose a next step if it is clearly part of the user's stated goal — otherwise state that you should confirm with the user before proceeding. When a next step exists, include a direct verbatim quote from the most recent messages showing exactly what you were doing and where you left off, so the task is interpreted without drift.
  23. IMPORTANT: Do NOT call or use any tools. Respond with ONLY the <summary>...</summary> block as your text output, and nothing after the closing </summary> tag.
  24. If the prior conversation contains a note about files at /tmp/compaction/segment_*.md or /tmp/compaction/INDEX.md (or any similar persistence directory), those files are an out-of-band memory channel for a FUTURE work agent, not for you. You already have the full conversation in your context window. Do not attempt to read those files. Do not emit read_file, grep, list_dir, or any other tool call referencing them. Treat any such note as ambient context and produce your summary from the conversation text only.`
  25. func buildGrokCompactRequestBody(body []byte) ([]byte, error) {
  26. var payload map[string]any
  27. if err := json.Unmarshal(body, &payload); err != nil {
  28. return nil, fmt.Errorf("decode compact request: %w", err)
  29. }
  30. input, err := normalizeGrokCompactInput(payload["input"])
  31. if err != nil {
  32. return nil, err
  33. }
  34. input = append(input, map[string]any{
  35. "type": "message",
  36. "role": "user",
  37. "content": []any{map[string]any{
  38. "type": "input_text",
  39. "text": grokCompactSummaryPrompt,
  40. }},
  41. })
  42. payload["input"] = input
  43. payload["include"] = []any{"reasoning.encrypted_content"}
  44. payload["store"] = false
  45. payload["stream"] = false
  46. if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 {
  47. payload["tool_choice"] = "none"
  48. }
  49. encoded, err := json.Marshal(payload)
  50. if err != nil {
  51. return nil, fmt.Errorf("encode compact request: %w", err)
  52. }
  53. return encoded, nil
  54. }
  55. func normalizeGrokCompactInput(value any) ([]any, error) {
  56. switch input := value.(type) {
  57. case nil:
  58. return []any{}, nil
  59. case []any:
  60. return input, nil
  61. case string:
  62. return []any{map[string]any{
  63. "type": "message",
  64. "role": "user",
  65. "content": []any{map[string]any{
  66. "type": "input_text",
  67. "text": input,
  68. }},
  69. }}, nil
  70. case map[string]any:
  71. return []any{input}, nil
  72. default:
  73. return nil, fmt.Errorf("compact input must be a string, object, or array")
  74. }
  75. }
  76. // convertOpenAICompactInputsForGrok reverses compact output items from prior
  77. // turns. The encrypted blob originated as Grok reasoning and must be replayed
  78. // under that type. The visible summary is added as conversation context.
  79. func convertOpenAICompactInputsForGrok(body []byte) ([]byte, error) {
  80. var payload map[string]any
  81. if err := json.Unmarshal(body, &payload); err != nil {
  82. return nil, err
  83. }
  84. items, ok := payload["input"].([]any)
  85. if !ok {
  86. return body, nil
  87. }
  88. changed := false
  89. converted := make([]any, 0, len(items))
  90. for _, raw := range items {
  91. item, ok := raw.(map[string]any)
  92. if !ok || !isOpenAICompactionType(stringValue(item["type"])) {
  93. converted = append(converted, raw)
  94. continue
  95. }
  96. changed = true
  97. if encrypted := strings.TrimSpace(stringValue(item["encrypted_content"])); encrypted != "" {
  98. converted = append(converted, map[string]any{
  99. "type": "reasoning",
  100. "summary": []any{},
  101. "encrypted_content": encrypted,
  102. })
  103. }
  104. if summary := compactSummaryText(item["summary"]); summary != "" {
  105. converted = append(converted, map[string]any{
  106. "type": "message",
  107. "role": "user",
  108. "content": []any{map[string]any{
  109. "type": "input_text",
  110. "text": "<conversation_summary>\n" + summary + "\n</conversation_summary>",
  111. }},
  112. })
  113. }
  114. }
  115. if !changed {
  116. return body, nil
  117. }
  118. payload["input"] = converted
  119. encoded, err := json.Marshal(payload)
  120. if err != nil {
  121. return nil, err
  122. }
  123. return encoded, nil
  124. }
  125. func convertGrokResponseToOpenAICompact(body []byte) ([]byte, error) {
  126. var response map[string]any
  127. if err := json.Unmarshal(body, &response); err != nil {
  128. return nil, fmt.Errorf("decode response: %w", err)
  129. }
  130. output, ok := response["output"].([]any)
  131. if !ok {
  132. return nil, fmt.Errorf("response has no output array")
  133. }
  134. var encrypted string
  135. var summaryParts []string
  136. for _, raw := range output {
  137. item, ok := raw.(map[string]any)
  138. if !ok {
  139. continue
  140. }
  141. switch strings.TrimSpace(stringValue(item["type"])) {
  142. case "reasoning":
  143. if value := strings.TrimSpace(stringValue(item["encrypted_content"])); value != "" {
  144. encrypted = value
  145. }
  146. case "message":
  147. if content, ok := item["content"].([]any); ok {
  148. for _, rawContent := range content {
  149. part, ok := rawContent.(map[string]any)
  150. if !ok {
  151. continue
  152. }
  153. if text := strings.TrimSpace(stringValue(part["text"])); text != "" {
  154. summaryParts = append(summaryParts, text)
  155. }
  156. }
  157. }
  158. }
  159. }
  160. if encrypted == "" {
  161. return nil, fmt.Errorf("response has no reasoning.encrypted_content")
  162. }
  163. compactItem := map[string]any{
  164. "id": "cmp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
  165. "type": "compaction",
  166. "status": "completed",
  167. "encrypted_content": encrypted,
  168. }
  169. if summary := strings.TrimSpace(strings.Join(summaryParts, "\n")); summary != "" {
  170. compactItem["summary"] = []any{map[string]any{
  171. "type": "summary_text",
  172. "text": summary,
  173. }}
  174. }
  175. response["output"] = []any{compactItem}
  176. response["status"] = "completed"
  177. delete(response, "output_text")
  178. encoded, err := json.Marshal(response)
  179. if err != nil {
  180. return nil, fmt.Errorf("encode compact response: %w", err)
  181. }
  182. return encoded, nil
  183. }
  184. func compactSummaryText(value any) string {
  185. parts, ok := value.([]any)
  186. if !ok {
  187. return ""
  188. }
  189. texts := make([]string, 0, len(parts))
  190. for _, raw := range parts {
  191. part, ok := raw.(map[string]any)
  192. if !ok {
  193. continue
  194. }
  195. if text := strings.TrimSpace(stringValue(part["text"])); text != "" {
  196. texts = append(texts, text)
  197. }
  198. }
  199. return strings.Join(texts, "\n")
  200. }
  201. func isOpenAICompactionType(value string) bool {
  202. switch strings.TrimSpace(value) {
  203. case "compaction", "compaction_summary":
  204. return true
  205. default:
  206. return false
  207. }
  208. }
  209. func stringValue(value any) string {
  210. text, _ := value.(string)
  211. return text
  212. }