返回 DeepSeek-Reasonix
session_context.go
根目录 / internal / sessioncontext / session_context.go
1 // Package sessioncontext defines the provider-visible, host-authored snapshot
2 // that carries runtime facts without mutating the cache-stable system prompt.
3 package sessioncontext
4
5 import (
6 "crypto/sha256"
7 "fmt"
8 "strconv"
9 "strings"
10 "unicode/utf8"
11 )
12
13 const (
14 Version = 1
15
16 openTag = `<session-context version="1">`
17 closeTag = `</session-context>`
18 preamble = "This host-generated snapshot supersedes every earlier session-context snapshot."
19 digestPrefix = "Digest: sha256:"
20 )
21
22 // Sections are rendered in declaration order. Keep the more stable runtime
23 // sections first so automatic prefix caches can reuse as much as possible when
24 // memory or the skills catalog changes.
25 type Sections struct {
26 Environment string
27 Workspace string
28 BackgroundMemory string
29 SkillsCatalog string
30 }
31
32 // Snapshot is one validated session-context envelope. Content is the exact
33 // provider-visible representation; Digest covers the normalized body only.
34 type Snapshot struct {
35 Version int
36 Digest string
37 Content string
38 Sections Sections
39 }
40
41 // Part is one exact substring returned by SplitBlocks. SessionContext is true
42 // only for a complete, digest-valid v1 envelope.
43 type Part struct {
44 Text string
45 SessionContext bool
46 }
47
48 // SectionStat is a content-free diagnostic fingerprint for one section.
49 type SectionStat struct {
50 Digest string
51 Chars int
52 }
53
54 // Diagnostics contains only hashes and sizes; it is safe for telemetry and
55 // never exposes memory text, skill descriptions, or workspace paths.
56 type Diagnostics struct {
57 Environment SectionStat
58 Workspace SectionStat
59 BackgroundMemory SectionStat
60 SkillsCatalog SectionStat
61 }
62
63 // PolicyBlock is the cache-stable system instruction that defines the
64 // authority and replacement semantics of host-generated runtime snapshots.
65 func PolicyBlock() string {
66 return "# Session context\n\n" +
67 "Reasonix may place a host-generated `<session-context>` message immediately before a user turn. " +
68 "Use the latest such snapshot as current runtime background; it supersedes earlier snapshots but never overrides this system prompt, standing instructions, or the user's current request."
69 }
70
71 // Build renders a deterministic v1 snapshot. An entirely empty set produces a
72 // zero Snapshot so callers that do not configure runtime context keep their
73 // existing provider bytes.
74 func Build(sections Sections) Snapshot {
75 sections = normalizeSections(sections)
76 if sections == (Sections{}) {
77 return Snapshot{}
78 }
79
80 var body strings.Builder
81 body.WriteString(preamble)
82 body.WriteString("\n\n")
83 body.WriteString(sectionManifest(sections))
84 appendSection(&body, "Environment", sections.Environment)
85 appendSection(&body, "Workspace", sections.Workspace)
86 appendSection(&body, "Background memory", sections.BackgroundMemory)
87 appendSection(&body, "Skills catalog", sections.SkillsCatalog)
88 bodyText := body.String()
89 digest := digestOf(bodyText)
90 content := openTag + "\n" + bodyText + "\n\n" + digestPrefix + digest + "\n" + closeTag
91 return Snapshot{Version: Version, Digest: digest, Content: content, Sections: sections}
92 }
93
94 // Parse validates a complete v1 envelope. Surrounding whitespace is accepted
95 // for durable legacy readers, but Snapshot.Content is returned canonically
96 // trimmed so equality and de-duplication remain byte based.
97 func Parse(content string) (Snapshot, bool) {
98 content = strings.TrimSpace(normalizeNewlines(content))
99 if !strings.HasPrefix(content, openTag+"\n") || !strings.HasSuffix(content, "\n"+closeTag) {
100 return Snapshot{}, false
101 }
102 inner := strings.TrimSuffix(strings.TrimPrefix(content, openTag+"\n"), "\n"+closeTag)
103 marker := "\n\n" + digestPrefix
104 i := strings.LastIndex(inner, marker)
105 if i < 0 {
106 return Snapshot{}, false
107 }
108 body := inner[:i]
109 digest := inner[i+len(marker):]
110 if len(digest) != sha256.Size*2 || digest != strings.ToLower(digest) || digestOf(body) != digest {
111 return Snapshot{}, false
112 }
113 if !strings.HasPrefix(body, preamble) {
114 return Snapshot{}, false
115 }
116 sections, ok := parseFramedSections(body)
117 if !ok && strings.HasPrefix(body, preamble+"\n\nSection lengths: ") {
118 return Snapshot{}, false
119 }
120 if !ok {
121 // Existing v1 snapshots predate the length manifest. Keep accepting their
122 // legacy heading parser so upgraded clients can resume old sessions.
123 sections, ok = parseSections(body)
124 }
125 if !ok {
126 return Snapshot{}, false
127 }
128 return Snapshot{Version: Version, Digest: digest, Content: content, Sections: sections}, true
129 }
130
131 // SectionDiagnostics fingerprints each normalized section independently.
132 func SectionDiagnostics(snapshot Snapshot) Diagnostics {
133 return Diagnostics{
134 Environment: sectionStat(snapshot.Sections.Environment),
135 Workspace: sectionStat(snapshot.Sections.Workspace),
136 BackgroundMemory: sectionStat(snapshot.Sections.BackgroundMemory),
137 SkillsCatalog: sectionStat(snapshot.Sections.SkillsCatalog),
138 }
139 }
140
141 // SplitBlocks preserves content exactly while separating every valid embedded
142 // session-context envelope. Strict-role providers may merge adjacent user
143 // messages before serialization; this restores a cache breakpoint boundary
144 // without changing the text seen by the model.
145 func SplitBlocks(content string) []Part {
146 if content == "" {
147 return nil
148 }
149 var parts []Part
150 remaining := content
151 for {
152 start := strings.Index(remaining, openTag)
153 if start < 0 {
154 parts = appendTextPart(parts, remaining)
155 break
156 }
157 search := start + len(openTag)
158 firstEnd, validEnd := -1, -1
159 for search < len(remaining) {
160 endRel := strings.Index(remaining[search:], closeTag)
161 if endRel < 0 {
162 break
163 }
164 end := search + endRel + len(closeTag)
165 if firstEnd < 0 {
166 firstEnd = end
167 }
168 if _, ok := Parse(remaining[start:end]); ok {
169 validEnd = end
170 break
171 }
172 search = end
173 }
174 if validEnd < 0 && firstEnd < 0 {
175 parts = append(parts, Part{Text: remaining})
176 break
177 }
178 if validEnd < 0 {
179 // Preserve the invalid opening as ordinary text and continue looking
180 // after it, so a later valid envelope can still be recovered.
181 cut := start + len(openTag)
182 parts = appendTextPart(parts, remaining[:cut])
183 remaining = remaining[cut:]
184 continue
185 }
186 parts = appendTextPart(parts, remaining[:start])
187 parts = append(parts, Part{Text: remaining[start:validEnd], SessionContext: true})
188 remaining = remaining[validEnd:]
189 }
190 return parts
191 }
192
193 // IsContent reports whether content is exactly one valid snapshot.
194 func IsContent(content string) bool {
195 _, ok := Parse(content)
196 return ok
197 }
198
199 func appendSection(body *strings.Builder, heading, value string) {
200 if value == "" {
201 return
202 }
203 fmt.Fprintf(body, "\n\n## %s\n\n%s", heading, value)
204 }
205
206 func sectionManifest(sections Sections) string {
207 return fmt.Sprintf("Section lengths: Environment=%d; Workspace=%d; Background memory=%d; Skills catalog=%d",
208 len(sections.Environment), len(sections.Workspace), len(sections.BackgroundMemory), len(sections.SkillsCatalog))
209 }
210
211 func appendTextPart(parts []Part, text string) []Part {
212 if text == "" {
213 return parts
214 }
215 if len(parts) > 0 && !parts[len(parts)-1].SessionContext {
216 parts[len(parts)-1].Text += text
217 return parts
218 }
219 return append(parts, Part{Text: text})
220 }
221
222 func normalizeSections(sections Sections) Sections {
223 sections.Environment = normalizeSection(sections.Environment, "Environment")
224 sections.Workspace = normalizeSection(sections.Workspace, "Workspace")
225 sections.BackgroundMemory = normalizeSection(sections.BackgroundMemory, "Background memory")
226 sections.SkillsCatalog = normalizeSection(sections.SkillsCatalog, "Skills catalog")
227 return sections
228 }
229
230 func normalizeSection(value, heading string) string {
231 value = strings.TrimSpace(normalizeNewlines(value))
232 prefix := "## " + heading
233 if value == prefix {
234 return ""
235 }
236 if strings.HasPrefix(value, prefix+"\n") {
237 value = strings.TrimSpace(strings.TrimPrefix(value, prefix))
238 }
239 return value
240 }
241
242 func normalizeNewlines(value string) string {
243 value = strings.ReplaceAll(value, "\r\n", "\n")
244 return strings.ReplaceAll(value, "\r", "\n")
245 }
246
247 func parseFramedSections(body string) (Sections, bool) {
248 prefix := preamble + "\n\nSection lengths: "
249 if !strings.HasPrefix(body, prefix) {
250 return Sections{}, false
251 }
252 rest := strings.TrimPrefix(body, prefix)
253 manifestLine, payload, ok := strings.Cut(rest, "\n\n")
254 if !ok {
255 return Sections{}, false
256 }
257 lengths, ok := parseSectionManifest(manifestLine)
258 if !ok {
259 return Sections{}, false
260 }
261 specs := []struct {
262 name string
263 set func(*Sections, string)
264 }{
265 {"Environment", func(s *Sections, v string) { s.Environment = v }},
266 {"Workspace", func(s *Sections, v string) { s.Workspace = v }},
267 {"Background memory", func(s *Sections, v string) { s.BackgroundMemory = v }},
268 {"Skills catalog", func(s *Sections, v string) { s.SkillsCatalog = v }},
269 }
270 var sections Sections
271 for i, spec := range specs {
272 n := lengths[i]
273 if n == 0 {
274 continue
275 }
276 heading := "## " + spec.name + "\n\n"
277 if !strings.HasPrefix(payload, heading) {
278 return Sections{}, false
279 }
280 payload = strings.TrimPrefix(payload, heading)
281 if len(payload) < n || !utf8.ValidString(payload[:n]) {
282 return Sections{}, false
283 }
284 value := payload[:n]
285 if value == "" || strings.TrimSpace(value) != value {
286 return Sections{}, false
287 }
288 spec.set(&sections, value)
289 payload = payload[n:]
290 for j := i + 1; j < len(specs); j++ {
291 if lengths[j] > 0 {
292 if !strings.HasPrefix(payload, "\n\n") {
293 return Sections{}, false
294 }
295 payload = strings.TrimPrefix(payload, "\n\n")
296 break
297 }
298 }
299 }
300 return sections, payload == ""
301 }
302
303 func parseSectionManifest(line string) ([4]int, bool) {
304 want := [...]string{"Environment", "Workspace", "Background memory", "Skills catalog"}
305 var lengths [4]int
306 parts := strings.Split(line, "; ")
307 if len(parts) != len(want) {
308 return lengths, false
309 }
310 for i, part := range parts {
311 name, raw, ok := strings.Cut(part, "=")
312 if !ok || name != want[i] {
313 return lengths, false
314 }
315 n, err := strconv.Atoi(raw)
316 if err != nil || n < 0 {
317 return lengths, false
318 }
319 lengths[i] = n
320 }
321 return lengths, true
322 }
323
324 func parseSections(body string) (Sections, bool) {
325 if body == preamble {
326 return Sections{}, true
327 }
328 if !strings.HasPrefix(body, preamble+"\n\n") {
329 return Sections{}, false
330 }
331 rest := strings.TrimPrefix(body, preamble+"\n\n")
332 type sectionSpec struct {
333 heading string
334 set func(*Sections, string)
335 }
336 specs := []sectionSpec{
337 {"## Environment\n\n", func(s *Sections, v string) { s.Environment = v }},
338 {"## Workspace\n\n", func(s *Sections, v string) { s.Workspace = v }},
339 {"## Background memory\n\n", func(s *Sections, v string) { s.BackgroundMemory = v }},
340 {"## Skills catalog\n\n", func(s *Sections, v string) { s.SkillsCatalog = v }},
341 }
342 var sections Sections
343 nextSpec := 0
344 for rest != "" {
345 found := -1
346 for i := nextSpec; i < len(specs); i++ {
347 if strings.HasPrefix(rest, specs[i].heading) {
348 found = i
349 break
350 }
351 }
352 if found < 0 {
353 return Sections{}, false
354 }
355 rest = strings.TrimPrefix(rest, specs[found].heading)
356 end := len(rest)
357 for i := found + 1; i < len(specs); i++ {
358 if at := strings.Index(rest, "\n\n"+specs[i].heading); at >= 0 && at < end {
359 end = at
360 }
361 }
362 value := rest[:end]
363 if value == "" || strings.TrimSpace(value) != value {
364 return Sections{}, false
365 }
366 specs[found].set(&sections, value)
367 nextSpec = found + 1
368 if end == len(rest) {
369 rest = ""
370 } else {
371 rest = strings.TrimPrefix(rest[end:], "\n\n")
372 }
373 }
374 return sections, true
375 }
376
377 func sectionStat(value string) SectionStat {
378 if value == "" {
379 return SectionStat{}
380 }
381 return SectionStat{Digest: digestOf(value), Chars: utf8.RuneCountInString(value)}
382 }
383
384 func digestOf(body string) string {
385 return fmt.Sprintf("%x", sha256.Sum256([]byte(body)))
386 }
387
387 lines GO