dynamic_context.go RawHistoryFolder
1package core
2
3import (
4 "strings"
5 "time"
6
7 "github.com/bnema/zut/packages/provider"
8)
9
10const internalContextMarker = "internal_context"
11
12// appendDynamicContext persists a provider-visible, host-authored context item
13// only when its canonical value changes. It is deliberately separate from the
14// stable top-level instructions so changing time or extension state cannot
15// rewrite the instruction cache prefix.
16func (a *Agent) appendDynamicContext(hostContext, turnContext string) {
17 parts := make([]string, 0, 3)
18 if timeContext := a.providerTimeContext().developerText(); timeContext != "" {
19 parts = append(parts, timeContext)
20 }
21 if hostContext = boundedTurnContext(hostContext); hostContext != "" {
22 parts = append(parts, hostContext)
23 }
24 if turnContext = boundedTurnContext(turnContext); turnContext != "" {
25 parts = append(parts, "[Extension context]\n"+turnContext)
26 }
27 text := strings.Join(parts, "\n\n")
28 if text == "" {
29 return
30 }
31
32 message := provider.Message{
33 Role: provider.RoleDeveloper,
34 Content: []provider.Content{provider.TextBlock{Text: text}},
35 Time: time.Now(),
36 Meta: map[string]string{internalContextMarker: "true"},
37 }
38
39 a.mu.Lock()
40 for i := len(a.messages) - 1; i >= 0; i-- {
41 if !isInternalContextMessage(a.messages[i]) {
42 continue
43 }
44 if internalContextText(a.messages[i]) == text {
45 a.mu.Unlock()
46 return
47 }
48 break
49 }
50 // The first dynamic item must precede the accepted user task. Persisting it
51 // as an append would reverse that order on session reload, so report the
52 // resulting transcript replacement instead. Later snapshots extend the
53 // history at the next model boundary and remain ordinary appends.
54 first := !hasInternalContextMessage(a.messages)
55 insertAt := len(a.messages)
56 if first {
57 insertAt = 0
58 }
59 a.messages = append(a.messages, provider.Message{})
60 copy(a.messages[insertAt+1:], a.messages[insertAt:])
61 a.messages[insertAt] = message
62 a.rev++
63 onCompacted := a.OnTranscriptCompacted
64 persisted := append([]provider.Message(nil), a.messages...)
65 a.mu.Unlock()
66 if first && onCompacted != nil {
67 onCompacted(persisted)
68 return
69 }
70 a.fireMessageAppended(message)
71}
72
73func hasInternalContextMessage(messages []provider.Message) bool {
74 for _, message := range messages {
75 if isInternalContextMessage(message) {
76 return true
77 }
78 }
79 return false
80}
81
82func isInternalContextMessage(message provider.Message) bool {
83 return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true"
84}
85
86// IsInternalContextMessage reports whether message is host-authored context
87// injected by the agent loop, such as a time snapshot or an extension
88// reminder, rather than finalized conversation history. Hosts that persist a
89// replacement transcript must exclude these messages: their value belongs to
90// the live request and replaying a stale copy would contradict it.
91func IsInternalContextMessage(message provider.Message) bool {
92 return isInternalContextMessage(message)
93}
94
95func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) {
96 filtered := make([]provider.Message, 0, len(messages))
97 var latest *provider.Message
98 for _, message := range messages {
99 if isInternalContextMessage(message) {
100 copy := message
101 latest = ©
102 continue
103 }
104 filtered = append(filtered, message)
105 }
106 return latest, filtered
107}
108
109func internalContextText(message provider.Message) string {
110 if len(message.Content) != 1 {
111 return ""
112 }
113 text, _ := message.Content[0].(provider.TextBlock)
114 return text.Text
115}