@@ -4,18 +4,49 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "strings"
78 "sync/atomic"
9+
10+ "github.com/threatwinds/go-sdk/catcher"
11+ )
12+
13+ const (
14+ defaultMaxIters = 12
15+ compactionThreshold = 0.80
16+ summaryMaxTokens = 400 // ~200 words + slack
817)
918
10- const defaultMaxIters = 12
19+ // ponytail: prefix map, kept short. Add entries when a new model family lands.
20+ var modelContextWindow = []struct {
21+ prefix string
22+ window int
23+ }{
24+ {"claude" , 200000 },
25+ {"gpt-5" , 400000 },
26+ {"gpt-4o" , 128000 },
27+ {"gpt-4-turbo" , 128000 },
28+ {"gpt-4" , 8192 },
29+ {"gpt-3.5" , 16385 },
30+ }
31+
32+ func resolveContextWindow (model string ) int {
33+ m := strings .ToLower (model )
34+ for _ , e := range modelContextWindow {
35+ if strings .HasPrefix (m , e .prefix ) {
36+ return e .window
37+ }
38+ }
39+ return 128000
40+ }
1141
1242type EventKind string
1343
1444const (
15- EventToolCall EventKind = "tool_call"
16- EventToolResult EventKind = "tool_result"
17- EventFinal EventKind = "final"
18- EventError EventKind = "error"
45+ EventToolCall EventKind = "tool_call"
46+ EventToolResult EventKind = "tool_result"
47+ EventFinal EventKind = "final"
48+ EventError EventKind = "error"
49+ EventCompaction EventKind = "compaction"
1950)
2051
2152type Event struct {
@@ -51,14 +82,21 @@ type RunResult struct {
5182}
5283
5384type Agent struct {
54- llm LLMClient
55- broker * ToolBroker
56- model string
57- maxTokens int
85+ llm LLMClient
86+ broker * ToolBroker
87+ model string
88+ maxTokens int
89+ contextWindow int // 0 = compaction disabled
5890}
5991
60- func New (llm LLMClient , broker * ToolBroker , model string , maxTokens int ) * Agent {
61- return & Agent {llm : llm , broker : broker , model : model , maxTokens : maxTokens }
92+ func New (llm LLMClient , broker * ToolBroker , model string , maxTokens , contextWindow int ) * Agent {
93+ cw := contextWindow
94+ if cw == 0 {
95+ cw = resolveContextWindow (model )
96+ } else if cw < 0 {
97+ cw = 0
98+ }
99+ return & Agent {llm : llm , broker : broker , model : model , maxTokens : maxTokens , contextWindow : cw }
62100}
63101
64102func (a * Agent ) Broker () * ToolBroker { return a .broker }
@@ -85,6 +123,20 @@ func (a *Agent) Run(ctx context.Context, task RunTask, sink EventSink) (RunResul
85123
86124 for step := 1 ; step <= maxIters ; step ++ {
87125 result .Steps = step
126+
127+ if a .contextWindow > 0 && len (msgs ) > 1 &&
128+ estimateTokens (task .System , msgs ) >= int (compactionThreshold * float64 (a .contextWindow )) {
129+ newMsgs , cErr := a .compact (ctx , task .Input , msgs )
130+ if cErr != nil {
131+ _ = catcher .Error ("context compaction failed, continuing with full history" , cErr , map [string ]any {
132+ "process" : "plugin_com.utmstack.soc-ai" ,
133+ })
134+ } else {
135+ msgs = newMsgs
136+ sink .emit (Event {Kind : EventCompaction , Step : step })
137+ }
138+ }
139+
88140 resp , err := a .llm .Complete (ctx , CompletionRequest {
89141 System : task .System ,
90142 Messages : msgs ,
@@ -154,6 +206,50 @@ func filterTools(specs []ToolSpec, task RunTask) []ToolSpec {
154206 return out
155207}
156208
209+ // ponytail: chars/4 estimate; swap for a real tokenizer only if it misdetects
210+ func estimateTokens (system string , msgs []Message ) int {
211+ n := len (system )
212+ for _ , m := range msgs {
213+ n += len (m .Content )
214+ for _ , tc := range m .ToolCalls {
215+ n += len (tc .Name ) + len (tc .Args )
216+ }
217+ if m .ToolResult != nil {
218+ n += len (m .ToolResult .Content ) + len (m .ToolResult .Name )
219+ }
220+ }
221+ return n / 4
222+ }
223+
224+ func (a * Agent ) compact (ctx context.Context , userInput string , msgs []Message ) ([]Message , error ) {
225+ var b strings.Builder
226+ for _ , m := range msgs {
227+ fmt .Fprintf (& b , "[%s] %s\n " , m .Role , m .Content )
228+ for _ , tc := range m .ToolCalls {
229+ fmt .Fprintf (& b , " tool_call %s(%s)\n " , tc .Name , string (tc .Args ))
230+ }
231+ if m .ToolResult != nil {
232+ fmt .Fprintf (& b , " tool_result %s: %s\n " , m .ToolResult .Name , m .ToolResult .Content )
233+ }
234+ }
235+ resp , err := a .llm .Complete (ctx , CompletionRequest {
236+ System : "Summarize the following SOC analyst conversation in ~200 words. Preserve key facts, tool outputs, decisions, and unresolved next steps. Do not use tools." ,
237+ Messages : []Message {{Role : RoleUser , Content : b .String ()}},
238+ Model : a .model ,
239+ MaxTokens : summaryMaxTokens ,
240+ })
241+ if err != nil {
242+ return msgs , err
243+ }
244+ if strings .TrimSpace (resp .Content ) == "" {
245+ return msgs , fmt .Errorf ("empty summary" )
246+ }
247+ return []Message {{
248+ Role : RoleUser ,
249+ Content : "Original task:\n " + userInput + "\n \n Progress so far (summary of prior context):\n " + resp .Content + "\n \n Continue the task." ,
250+ }}, nil
251+ }
252+
157253var current atomic.Pointer [Agent ]
158254
159255func SetCurrent (a * Agent ) {
0 commit comments