recursive-llm-ts 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ package rlm
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ )
7
+
8
+ // ─── Infinite Delegation Guard ──────────────────────────────────────────────
9
+ // Implements the scope-reduction invariant from the LCM paper (Section 3.2).
10
+ //
11
+ // When a sub-agent spawns a further sub-agent, it must declare:
12
+ // - delegated_scope: the specific slice of work being handed off
13
+ // - kept_work: the work the caller will still perform itself
14
+ //
15
+ // If the caller cannot articulate what it's retaining (i.e., it would delegate
16
+ // its entire responsibility), the call is rejected. This forces each level of
17
+ // delegation to represent a strict reduction in responsibility.
18
+ //
19
+ // Exemptions:
20
+ // - Root agent (depth 0): no parent to recurse with
21
+ // - Read-only agents: cannot spawn further sub-agents
22
+ // - Parallel decomposition (sibling tasks): not nested delegation
23
+
24
+ // DelegationRequest represents a request to delegate work to a sub-agent.
25
+ type DelegationRequest struct {
26
+ // Prompt is the task description for the sub-agent.
27
+ Prompt string `json:"prompt"`
28
+
29
+ // DelegatedScope describes the specific slice of work being handed off.
30
+ // Required for non-root agents.
31
+ DelegatedScope string `json:"delegated_scope"`
32
+
33
+ // KeptWork describes the work the caller retains for itself.
34
+ // Required for non-root agents. Must be non-empty and distinct from DelegatedScope.
35
+ KeptWork string `json:"kept_work"`
36
+
37
+ // ReadOnly indicates this is a read-only exploration agent (exempt from guard).
38
+ ReadOnly bool `json:"read_only"`
39
+
40
+ // Parallel indicates this is parallel decomposition (exempt from guard).
41
+ Parallel bool `json:"parallel"`
42
+ }
43
+
44
+ // DelegationGuard enforces the scope-reduction invariant.
45
+ type DelegationGuard struct {
46
+ observer *Observer
47
+ }
48
+
49
+ // NewDelegationGuard creates a new delegation guard.
50
+ func NewDelegationGuard(observer *Observer) *DelegationGuard {
51
+ return &DelegationGuard{observer: observer}
52
+ }
53
+
54
+ // DelegationError is returned when a delegation request violates the scope-reduction invariant.
55
+ type DelegationError struct {
56
+ Reason string `json:"reason"`
57
+ Suggestion string `json:"suggestion"`
58
+ }
59
+
60
+ func (e *DelegationError) Error() string {
61
+ return fmt.Sprintf("delegation rejected: %s. %s", e.Reason, e.Suggestion)
62
+ }
63
+
64
+ // ValidateDelegation checks if a delegation request is allowed at the given depth.
65
+ // Returns nil if allowed, or a DelegationError explaining why it was rejected.
66
+ func (g *DelegationGuard) ValidateDelegation(depth int, req DelegationRequest) error {
67
+ // Root agent (depth 0) is always allowed to delegate
68
+ if depth == 0 {
69
+ g.observer.Debug("lcm.delegation", "Root agent delegation allowed (depth 0)")
70
+ return nil
71
+ }
72
+
73
+ // Read-only agents are exempt (they can't spawn further sub-agents)
74
+ if req.ReadOnly {
75
+ g.observer.Debug("lcm.delegation", "Read-only agent delegation allowed")
76
+ return nil
77
+ }
78
+
79
+ // Parallel decomposition is exempt (sibling, not nested)
80
+ if req.Parallel {
81
+ g.observer.Debug("lcm.delegation", "Parallel decomposition delegation allowed")
82
+ return nil
83
+ }
84
+
85
+ // Non-root agents must declare scope reduction
86
+ if strings.TrimSpace(req.DelegatedScope) == "" {
87
+ g.observer.Debug("lcm.delegation", "Delegation rejected: no delegated_scope at depth %d", depth)
88
+ return &DelegationError{
89
+ Reason: "sub-agent must declare delegated_scope",
90
+ Suggestion: "Describe the specific slice of work being handed off, or perform the work directly.",
91
+ }
92
+ }
93
+
94
+ if strings.TrimSpace(req.KeptWork) == "" {
95
+ g.observer.Debug("lcm.delegation", "Delegation rejected: no kept_work at depth %d", depth)
96
+ return &DelegationError{
97
+ Reason: "sub-agent must declare kept_work (what the caller retains)",
98
+ Suggestion: "If you cannot articulate what you're retaining, perform the work directly instead of delegating.",
99
+ }
100
+ }
101
+
102
+ // Check for full delegation (delegated_scope ≈ entire task)
103
+ if isTotalDelegation(req.DelegatedScope, req.KeptWork) {
104
+ g.observer.Debug("lcm.delegation", "Delegation rejected: total delegation detected at depth %d", depth)
105
+ return &DelegationError{
106
+ Reason: "delegated_scope appears to encompass the entire task; kept_work is trivial",
107
+ Suggestion: "Break the task into meaningful subtasks where you retain substantial work, or perform it directly.",
108
+ }
109
+ }
110
+
111
+ g.observer.Debug("lcm.delegation", "Delegation allowed at depth %d: scope=%q, kept=%q",
112
+ depth, truncateStr(req.DelegatedScope, 80), truncateStr(req.KeptWork, 80))
113
+ return nil
114
+ }
115
+
116
+ // isTotalDelegation detects when an agent is trying to delegate its entire responsibility.
117
+ // This is a heuristic check — it catches obvious cases of trivial kept_work.
118
+ func isTotalDelegation(delegatedScope, keptWork string) bool {
119
+ kept := strings.TrimSpace(strings.ToLower(keptWork))
120
+
121
+ // Trivial kept_work patterns that indicate full delegation
122
+ trivialPatterns := []string{
123
+ "none",
124
+ "nothing",
125
+ "n/a",
126
+ "na",
127
+ "",
128
+ "will wait",
129
+ "waiting",
130
+ "just wait",
131
+ "aggregate",
132
+ "collect results",
133
+ "return results",
134
+ "pass through",
135
+ "forward",
136
+ }
137
+
138
+ for _, pattern := range trivialPatterns {
139
+ if kept == pattern {
140
+ return true
141
+ }
142
+ }
143
+
144
+ // Check if kept_work is suspiciously short compared to delegated_scope
145
+ // (less than 10% of the delegated scope's length and under 20 chars)
146
+ if len(kept) < 20 && len(kept) < len(delegatedScope)/10 {
147
+ return true
148
+ }
149
+
150
+ return false
151
+ }
152
+
153
+ // ─── Integration with RLM Engine ────────────────────────────────────────────
154
+
155
+ // DelegateTask validates and executes a delegation request through the RLM engine.
156
+ // This is the main entry point for task delegation with the infinite recursion guard.
157
+ func (r *RLM) DelegateTask(req DelegationRequest) (string, RLMStats, error) {
158
+ // Create or use existing delegation guard
159
+ guard := NewDelegationGuard(r.observer)
160
+
161
+ // Validate the delegation
162
+ if err := guard.ValidateDelegation(r.currentDepth, req); err != nil {
163
+ return "", RLMStats{}, err
164
+ }
165
+
166
+ // Create sub-agent
167
+ subConfig := Config{
168
+ RecursiveModel: r.recursiveModel,
169
+ APIBase: r.apiBase,
170
+ APIKey: r.apiKey,
171
+ MaxDepth: r.maxDepth,
172
+ MaxIterations: r.maxIterations,
173
+ TimeoutSeconds: r.timeoutSeconds,
174
+ UseMetacognitive: r.useMetacognitive,
175
+ ExtraParams: r.extraParams,
176
+ }
177
+
178
+ subRLM := New(r.recursiveModel, subConfig)
179
+ subRLM.currentDepth = r.currentDepth + 1
180
+ subRLM.observer = r.observer
181
+ defer subRLM.Shutdown()
182
+
183
+ r.observer.Debug("lcm.delegation", "Spawning sub-agent at depth %d for: %s",
184
+ r.currentDepth+1, truncateStr(req.Prompt, 100))
185
+
186
+ result, stats, err := subRLM.Completion(req.Prompt, "")
187
+ return result, stats, err
188
+ }
189
+
190
+ // DelegateTasks validates and executes multiple parallel delegation requests.
191
+ // This implements the Tasks() tool from the LCM paper (Appendix C.3).
192
+ // Parallel decomposition is exempt from the recursion guard.
193
+ func (r *RLM) DelegateTasks(tasks []DelegationRequest) ([]string, []RLMStats, error) {
194
+ if len(tasks) < 2 {
195
+ return nil, nil, fmt.Errorf("DelegateTasks requires at least 2 tasks for parallel decomposition")
196
+ }
197
+
198
+ guard := NewDelegationGuard(r.observer)
199
+
200
+ // Mark all as parallel (exempt from guard) but still validate basic structure
201
+ for i := range tasks {
202
+ tasks[i].Parallel = true
203
+ if err := guard.ValidateDelegation(r.currentDepth, tasks[i]); err != nil {
204
+ return nil, nil, fmt.Errorf("task %d validation failed: %w", i, err)
205
+ }
206
+ }
207
+
208
+ r.observer.Debug("lcm.delegation", "Spawning %d parallel sub-agents at depth %d",
209
+ len(tasks), r.currentDepth+1)
210
+
211
+ type taskResult struct {
212
+ index int
213
+ result string
214
+ stats RLMStats
215
+ err error
216
+ }
217
+
218
+ results := make(chan taskResult, len(tasks))
219
+
220
+ for i, task := range tasks {
221
+ go func(idx int, t DelegationRequest) {
222
+ subConfig := Config{
223
+ RecursiveModel: r.recursiveModel,
224
+ APIBase: r.apiBase,
225
+ APIKey: r.apiKey,
226
+ MaxDepth: r.maxDepth,
227
+ MaxIterations: r.maxIterations,
228
+ TimeoutSeconds: r.timeoutSeconds,
229
+ UseMetacognitive: r.useMetacognitive,
230
+ ExtraParams: r.extraParams,
231
+ }
232
+
233
+ subRLM := New(r.recursiveModel, subConfig)
234
+ subRLM.currentDepth = r.currentDepth + 1
235
+ subRLM.observer = r.observer
236
+ defer subRLM.Shutdown()
237
+
238
+ result, stats, err := subRLM.Completion(t.Prompt, "")
239
+ results <- taskResult{index: idx, result: result, stats: stats, err: err}
240
+ }(i, task)
241
+ }
242
+
243
+ // Collect results in order
244
+ resultSlice := make([]string, len(tasks))
245
+ statsSlice := make([]RLMStats, len(tasks))
246
+
247
+ for range tasks {
248
+ tr := <-results
249
+ if tr.err != nil {
250
+ return nil, nil, fmt.Errorf("parallel task %d failed: %w", tr.index, tr.err)
251
+ }
252
+ resultSlice[tr.index] = tr.result
253
+ statsSlice[tr.index] = tr.stats
254
+ }
255
+
256
+ return resultSlice, statsSlice, nil
257
+ }
@@ -0,0 +1,313 @@
1
+ package rlm
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "sync"
7
+ "time"
8
+ )
9
+
10
+ // EpisodeStatus describes lifecycle state for an episode.
11
+ type EpisodeStatus string
12
+
13
+ const (
14
+ // EpisodeActive is currently accumulating messages.
15
+ EpisodeActive EpisodeStatus = "active"
16
+ // EpisodeCompacted has a generated summary while messages remain available.
17
+ EpisodeCompacted EpisodeStatus = "compacted"
18
+ // EpisodeArchived is deeply compressed and represented by summary in active context.
19
+ EpisodeArchived EpisodeStatus = "archived"
20
+ )
21
+
22
+ // Episode is a coherent interaction unit that groups related messages.
23
+ type Episode struct {
24
+ ID string `json:"id"`
25
+ Title string `json:"title"`
26
+ MessageIDs []string `json:"message_ids"`
27
+ StartTime time.Time `json:"start_time"`
28
+ EndTime time.Time `json:"end_time"`
29
+ Tokens int `json:"tokens"`
30
+ Summary string `json:"summary,omitempty"`
31
+ SummaryTokens int `json:"summary_tokens,omitempty"`
32
+ Status EpisodeStatus `json:"status"`
33
+ Tags []string `json:"tags,omitempty"`
34
+ ParentEpisodeID string `json:"parent_episode_id,omitempty"`
35
+ }
36
+
37
+ // EpisodeConfig controls episode boundaries and behavior.
38
+ type EpisodeConfig struct {
39
+ MaxEpisodeTokens int
40
+ MaxEpisodeMessages int
41
+ TopicChangeThreshold float64
42
+ AutoCompactAfterClose bool
43
+ }
44
+
45
+ // EpisodeManager manages episode creation, compaction, and retrieval.
46
+ type EpisodeManager struct {
47
+ mu sync.RWMutex
48
+ episodes map[string]*Episode
49
+ episodeSeq []*Episode
50
+ activeEpisode *Episode
51
+ nextID int
52
+ sessionID string
53
+ config EpisodeConfig
54
+ }
55
+
56
+ // NewEpisodeManager creates a manager with defaults applied.
57
+ func NewEpisodeManager(sessionID string, config EpisodeConfig) *EpisodeManager {
58
+ zeroConfig := config == (EpisodeConfig{})
59
+
60
+ if config.MaxEpisodeTokens <= 0 {
61
+ config.MaxEpisodeTokens = 2000
62
+ }
63
+ if config.MaxEpisodeMessages <= 0 {
64
+ config.MaxEpisodeMessages = 20
65
+ }
66
+ if config.TopicChangeThreshold <= 0 || config.TopicChangeThreshold > 1 {
67
+ config.TopicChangeThreshold = 0.5
68
+ }
69
+ if zeroConfig {
70
+ config.AutoCompactAfterClose = true
71
+ }
72
+
73
+ return &EpisodeManager{
74
+ episodes: make(map[string]*Episode),
75
+ episodeSeq: make([]*Episode, 0),
76
+ sessionID: sessionID,
77
+ config: config,
78
+ }
79
+ }
80
+
81
+ // AddMessage adds a message into the active episode, rotating when needed.
82
+ func (m *EpisodeManager) AddMessage(msg *StoreMessage) {
83
+ if msg == nil {
84
+ return
85
+ }
86
+
87
+ m.mu.Lock()
88
+ defer m.mu.Unlock()
89
+
90
+ if m.activeEpisode == nil {
91
+ m.activeEpisode = m.newEpisodeLocked("")
92
+ }
93
+
94
+ if m.shouldCloseEpisodeLocked() {
95
+ closed := m.closeActiveEpisodeLocked()
96
+ parentID := ""
97
+ if closed != nil {
98
+ parentID = closed.ID
99
+ }
100
+ m.activeEpisode = m.newEpisodeLocked(parentID)
101
+ }
102
+
103
+ ep := m.activeEpisode
104
+ ep.MessageIDs = append(ep.MessageIDs, msg.ID)
105
+ ep.Tokens += msg.Tokens
106
+ if ep.StartTime.IsZero() {
107
+ ep.StartTime = msg.Timestamp
108
+ }
109
+ ep.EndTime = msg.Timestamp
110
+ if strings.TrimSpace(ep.Title) == "" {
111
+ ep.Title = buildEpisodeTitle(msg.Content)
112
+ }
113
+ if len(ep.Tags) == 0 {
114
+ ep.Tags = buildEpisodeTags(msg.Content)
115
+ }
116
+ }
117
+
118
+ // CloseActiveEpisode closes the current active episode.
119
+ func (m *EpisodeManager) CloseActiveEpisode() *Episode {
120
+ m.mu.Lock()
121
+ defer m.mu.Unlock()
122
+ return m.closeActiveEpisodeLocked()
123
+ }
124
+
125
+ // GetEpisode retrieves an episode by id.
126
+ func (m *EpisodeManager) GetEpisode(id string) (*Episode, bool) {
127
+ m.mu.RLock()
128
+ defer m.mu.RUnlock()
129
+ ep, ok := m.episodes[id]
130
+ return ep, ok
131
+ }
132
+
133
+ // GetAllEpisodes returns all episodes in chronological order.
134
+ func (m *EpisodeManager) GetAllEpisodes() []*Episode {
135
+ m.mu.RLock()
136
+ defer m.mu.RUnlock()
137
+ out := make([]*Episode, len(m.episodeSeq))
138
+ copy(out, m.episodeSeq)
139
+ return out
140
+ }
141
+
142
+ // GetActiveEpisode returns the currently active episode.
143
+ func (m *EpisodeManager) GetActiveEpisode() *Episode {
144
+ m.mu.RLock()
145
+ defer m.mu.RUnlock()
146
+ return m.activeEpisode
147
+ }
148
+
149
+ // CompactEpisode compresses an episode into a summary.
150
+ func (m *EpisodeManager) CompactEpisode(episodeID string, summary string) error {
151
+ m.mu.Lock()
152
+ defer m.mu.Unlock()
153
+
154
+ ep, ok := m.episodes[episodeID]
155
+ if !ok {
156
+ return fmt.Errorf("episode not found: %s", episodeID)
157
+ }
158
+
159
+ summary = strings.TrimSpace(summary)
160
+ if summary == "" {
161
+ return fmt.Errorf("summary cannot be empty")
162
+ }
163
+
164
+ ep.Summary = summary
165
+ ep.SummaryTokens = EstimateTokens(summary)
166
+ ep.Status = EpisodeCompacted
167
+ if strings.TrimSpace(ep.Title) == "" {
168
+ ep.Title = buildEpisodeTitle(summary)
169
+ }
170
+ if len(ep.Tags) == 0 {
171
+ ep.Tags = buildEpisodeTags(summary)
172
+ }
173
+
174
+ return nil
175
+ }
176
+
177
+ // GetEpisodesForContext returns reverse-chronological episodes within budget.
178
+ // Active episode is always included when present.
179
+ func (m *EpisodeManager) GetEpisodesForContext(tokenBudget int) []*Episode {
180
+ m.mu.RLock()
181
+ defer m.mu.RUnlock()
182
+
183
+ if tokenBudget < 0 {
184
+ tokenBudget = 0
185
+ }
186
+
187
+ result := make([]*Episode, 0)
188
+ included := make(map[string]bool)
189
+ remaining := tokenBudget
190
+
191
+ if m.activeEpisode != nil {
192
+ result = append(result, m.activeEpisode)
193
+ included[m.activeEpisode.ID] = true
194
+ remaining -= m.activeEpisode.Tokens
195
+ if remaining <= 0 {
196
+ return result
197
+ }
198
+ }
199
+
200
+ for i := len(m.episodeSeq) - 1; i >= 0; i-- {
201
+ ep := m.episodeSeq[i]
202
+ if ep == nil || included[ep.ID] {
203
+ continue
204
+ }
205
+
206
+ cost := ep.Tokens
207
+ if ep.Status != EpisodeActive {
208
+ if ep.SummaryTokens > 0 {
209
+ cost = ep.SummaryTokens
210
+ }
211
+ }
212
+
213
+ if cost > remaining {
214
+ continue
215
+ }
216
+
217
+ result = append(result, ep)
218
+ remaining -= cost
219
+ if remaining <= 0 {
220
+ break
221
+ }
222
+ }
223
+
224
+ return result
225
+ }
226
+
227
+ func (m *EpisodeManager) shouldCloseEpisodeLocked() bool {
228
+ if m.activeEpisode == nil {
229
+ return false
230
+ }
231
+ if m.config.MaxEpisodeTokens > 0 && m.activeEpisode.Tokens >= m.config.MaxEpisodeTokens {
232
+ return true
233
+ }
234
+ if m.config.MaxEpisodeMessages > 0 && len(m.activeEpisode.MessageIDs) >= m.config.MaxEpisodeMessages {
235
+ return true
236
+ }
237
+ return false
238
+ }
239
+
240
+ func (m *EpisodeManager) closeActiveEpisodeLocked() *Episode {
241
+ if m.activeEpisode == nil {
242
+ return nil
243
+ }
244
+
245
+ ep := m.activeEpisode
246
+ if ep.EndTime.IsZero() {
247
+ ep.EndTime = time.Now()
248
+ }
249
+ if ep.Status == EpisodeActive && m.config.AutoCompactAfterClose {
250
+ if strings.TrimSpace(ep.Summary) == "" {
251
+ ep.Summary = fmt.Sprintf("Episode %s (%d messages)", ep.ID, len(ep.MessageIDs))
252
+ }
253
+ ep.SummaryTokens = EstimateTokens(ep.Summary)
254
+ ep.Status = EpisodeCompacted
255
+ }
256
+
257
+ m.activeEpisode = nil
258
+ return ep
259
+ }
260
+
261
+ func (m *EpisodeManager) newEpisodeLocked(parentEpisodeID string) *Episode {
262
+ m.nextID++
263
+ ep := &Episode{
264
+ ID: fmt.Sprintf("ep_%s_%d", m.sessionID, m.nextID),
265
+ Title: "",
266
+ MessageIDs: make([]string, 0),
267
+ StartTime: time.Time{},
268
+ EndTime: time.Time{},
269
+ Tokens: 0,
270
+ Summary: "",
271
+ SummaryTokens: 0,
272
+ Status: EpisodeActive,
273
+ Tags: make([]string, 0),
274
+ ParentEpisodeID: parentEpisodeID,
275
+ }
276
+ m.episodes[ep.ID] = ep
277
+ m.episodeSeq = append(m.episodeSeq, ep)
278
+ return ep
279
+ }
280
+
281
+ func buildEpisodeTitle(content string) string {
282
+ trimmed := strings.TrimSpace(content)
283
+ if trimmed == "" {
284
+ return "Untitled Episode"
285
+ }
286
+ parts := strings.Fields(trimmed)
287
+ if len(parts) > 8 {
288
+ parts = parts[:8]
289
+ }
290
+ return strings.Join(parts, " ")
291
+ }
292
+
293
+ func buildEpisodeTags(content string) []string {
294
+ trimmed := strings.ToLower(strings.TrimSpace(content))
295
+ if trimmed == "" {
296
+ return nil
297
+ }
298
+ parts := strings.Fields(trimmed)
299
+ if len(parts) > 3 {
300
+ parts = parts[:3]
301
+ }
302
+ out := make([]string, 0, len(parts))
303
+ seen := make(map[string]bool)
304
+ for _, p := range parts {
305
+ p = strings.Trim(p, ",.;:!?()[]{}\"'")
306
+ if p == "" || seen[p] {
307
+ continue
308
+ }
309
+ seen[p] = true
310
+ out = append(out, p)
311
+ }
312
+ return out
313
+ }