saico 2.0.0 → 2.2.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.
- package/context.js +157 -31
- package/index.js +35 -1
- package/itask.js +94 -9
- package/package.json +12 -2
- package/sid.js +41 -6
- package/store.js +163 -0
- package/util.js +34 -0
package/context.js
CHANGED
|
@@ -34,6 +34,9 @@ class Context {
|
|
|
34
34
|
this._deferred_tool_calls = [];
|
|
35
35
|
this._tool_call_sequence = [];
|
|
36
36
|
|
|
37
|
+
// Chat history persistence
|
|
38
|
+
this.chat_history = config.chat_history || null;
|
|
39
|
+
|
|
37
40
|
this._msgs = [];
|
|
38
41
|
this._waitingQueue = [];
|
|
39
42
|
this._active_tool_calls = new Map();
|
|
@@ -43,6 +46,14 @@ class Context {
|
|
|
43
46
|
this._processing_sequential = false;
|
|
44
47
|
this._sequential_mode = config.sequential_mode || false;
|
|
45
48
|
|
|
49
|
+
// Queue structure limits
|
|
50
|
+
this.QUEUE_LIMIT = config.queue_limit ?? 30;
|
|
51
|
+
this.TOOL_DIGEST_LIMIT = config.tool_digest_limit ?? 10;
|
|
52
|
+
this.MIN_CHAT_MESSAGES = config.min_chat_messages ?? 10;
|
|
53
|
+
|
|
54
|
+
// Tool digest — persistent history of tool calls that mutated task state
|
|
55
|
+
this.tool_digest = config.tool_digest || [];
|
|
56
|
+
|
|
46
57
|
// Initialize messages if provided
|
|
47
58
|
(config.msgs || []).forEach(m => this.push(m));
|
|
48
59
|
|
|
@@ -58,6 +69,35 @@ class Context {
|
|
|
58
69
|
this.functions = task?.functions;
|
|
59
70
|
}
|
|
60
71
|
|
|
72
|
+
// Overridable: extending classes provide current state summary
|
|
73
|
+
getStateSummary() { return ''; }
|
|
74
|
+
|
|
75
|
+
// Snapshot all public (non-underscore) task properties for dirty detection.
|
|
76
|
+
// Mirrors the observable proxy convention: _ prefix = internal, ignored.
|
|
77
|
+
// Does NOT call serialize() — that is for persistence, not dirty detection.
|
|
78
|
+
_snapshotPublicProps(obj, seen = new Set()) {
|
|
79
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
80
|
+
if (seen.has(obj)) return undefined; // circular ref — skip
|
|
81
|
+
seen.add(obj);
|
|
82
|
+
const out = Array.isArray(obj) ? [] : {};
|
|
83
|
+
for (const key of Object.keys(obj)) {
|
|
84
|
+
if (!key.startsWith('_') && typeof obj[key] !== 'function')
|
|
85
|
+
out[key] = this._snapshotPublicProps(obj[key], seen);
|
|
86
|
+
}
|
|
87
|
+
seen.delete(obj);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Append a tool result to the persistent tool digest
|
|
92
|
+
_appendToolDigest(toolName, resultContent) {
|
|
93
|
+
const truncated = typeof resultContent === 'string'
|
|
94
|
+
? resultContent.slice(0, 500)
|
|
95
|
+
: String(resultContent ?? '').slice(0, 500);
|
|
96
|
+
this.tool_digest.push({ tool: toolName, result: truncated, tm: Date.now() });
|
|
97
|
+
if (this.tool_digest.length > this.TOOL_DIGEST_LIMIT)
|
|
98
|
+
this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
// Get the parent context by traversing task hierarchy
|
|
62
102
|
getParentContext() {
|
|
63
103
|
if (!this.task || !this.task.parent)
|
|
@@ -245,6 +285,8 @@ class Context {
|
|
|
245
285
|
};
|
|
246
286
|
} else {
|
|
247
287
|
this._trackActiveToolCall(call);
|
|
288
|
+
const _snap = this.task
|
|
289
|
+
? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
|
|
248
290
|
|
|
249
291
|
try {
|
|
250
292
|
const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
|
|
@@ -252,6 +294,9 @@ class Context {
|
|
|
252
294
|
const timeout = correspondingDeferred?.originalMessage.opts.timeout;
|
|
253
295
|
|
|
254
296
|
result = await this._executeToolCallWithTimeout(call, handler, timeout);
|
|
297
|
+
if (_snap !== null &&
|
|
298
|
+
_snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
|
|
299
|
+
this._appendToolDigest(call.function.name, result?.content || '');
|
|
255
300
|
} finally {
|
|
256
301
|
this._completeActiveToolCall(call);
|
|
257
302
|
}
|
|
@@ -397,6 +442,48 @@ class Context {
|
|
|
397
442
|
_log('Finished closing Context tag', this.tag);
|
|
398
443
|
}
|
|
399
444
|
|
|
445
|
+
// Load chat history from store into message queue
|
|
446
|
+
async loadHistory(store) {
|
|
447
|
+
if (!store || !this.tag)
|
|
448
|
+
return;
|
|
449
|
+
const data = await store.load(this.tag);
|
|
450
|
+
if (!data)
|
|
451
|
+
return;
|
|
452
|
+
if (Array.isArray(data.tool_digest))
|
|
453
|
+
this.tool_digest = data.tool_digest;
|
|
454
|
+
if (!data.chat_history)
|
|
455
|
+
return;
|
|
456
|
+
const messages = await util.decompressMessages(data.chat_history);
|
|
457
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
458
|
+
return;
|
|
459
|
+
// Find the index after the last system message to insert history
|
|
460
|
+
let insertIdx = 0;
|
|
461
|
+
for (let i = 0; i < this._msgs.length; i++) {
|
|
462
|
+
if (this._msgs[i].msg.role === 'system')
|
|
463
|
+
insertIdx = i + 1;
|
|
464
|
+
}
|
|
465
|
+
const historyMsgs = messages.map(m => ({
|
|
466
|
+
msg: m,
|
|
467
|
+
opts: {},
|
|
468
|
+
msgid: crypto.randomBytes(2).toString('hex'),
|
|
469
|
+
replied: 1
|
|
470
|
+
}));
|
|
471
|
+
this._msgs.splice(insertIdx, 0, ...historyMsgs);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Remove tool-related messages tagged with a specific tag
|
|
475
|
+
cleanToolCallsByTag(tag) {
|
|
476
|
+
this._msgs = this._msgs.filter(m => {
|
|
477
|
+
if (m.opts.tag !== tag)
|
|
478
|
+
return true;
|
|
479
|
+
if (m.msg.tool_calls)
|
|
480
|
+
return false;
|
|
481
|
+
if (m.msg.role === 'tool')
|
|
482
|
+
return false;
|
|
483
|
+
return true;
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
400
487
|
async _summarizeContext(close, targetCtx) {
|
|
401
488
|
const keep = this._msgs.filter(m => !close && m.summary);
|
|
402
489
|
const summarize = this._msgs.filter(m => (!close || !m.summary) && m.replied);
|
|
@@ -677,50 +764,82 @@ class Context {
|
|
|
677
764
|
return validatedMsgs;
|
|
678
765
|
}
|
|
679
766
|
|
|
680
|
-
//
|
|
767
|
+
// Slice the last `limit` messages, walking back to avoid orphaning tool responses
|
|
768
|
+
// and expanding if fewer than MIN_CHAT_MESSAGES user/assistant messages are included.
|
|
769
|
+
_getQueueSlice(msgs, limit) {
|
|
770
|
+
if (msgs.length <= limit) return msgs;
|
|
771
|
+
|
|
772
|
+
let startIdx = msgs.length - limit;
|
|
773
|
+
|
|
774
|
+
// Walk backward if we'd start mid-tool-pair (tool response without its call)
|
|
775
|
+
while (startIdx > 0 && msgs[startIdx] && msgs[startIdx].role === 'tool')
|
|
776
|
+
startIdx--;
|
|
777
|
+
|
|
778
|
+
// Count user/assistant messages in the current window
|
|
779
|
+
let chatCount = 0;
|
|
780
|
+
for (let i = startIdx; i < msgs.length; i++) {
|
|
781
|
+
const role = msgs[i].role;
|
|
782
|
+
if (role === 'user' || role === 'assistant') chatCount++;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Expand backward until we have at least MIN_CHAT_MESSAGES chat messages
|
|
786
|
+
while (chatCount < this.MIN_CHAT_MESSAGES && startIdx > 0) {
|
|
787
|
+
startIdx--;
|
|
788
|
+
const role = msgs[startIdx].role;
|
|
789
|
+
if (role === 'user' || role === 'assistant') chatCount++;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return msgs.slice(startIdx);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Build message queue — layered structure:
|
|
796
|
+
// Layer 1: System prompts from ancestor hierarchy + own prompt (transient)
|
|
797
|
+
// Layer 2: [State Summary] from getStateSummary() override (transient, if non-empty)
|
|
798
|
+
// Layer 3: [Tool Activity Log] from tool_digest (transient, if non-empty)
|
|
799
|
+
// Layer 4: Ancestor summaries only + last QUEUE_LIMIT of own messages (persistent)
|
|
681
800
|
_createMsgQ(add_tag, tag_filter) {
|
|
682
801
|
const fullQueue = [];
|
|
683
|
-
|
|
684
|
-
// Get messages from ancestor contexts via task hierarchy
|
|
685
802
|
const ancestorContexts = this.getAncestorContexts();
|
|
803
|
+
|
|
804
|
+
// Layer 1: System prompts from ancestor hierarchy + own prompt
|
|
686
805
|
for (const ctx of ancestorContexts) {
|
|
687
806
|
if (ctx.prompt) {
|
|
688
807
|
const prompt = {role: 'system', content: ctx.prompt};
|
|
689
|
-
if (add_tag)
|
|
690
|
-
prompt.tag = ctx.tag;
|
|
808
|
+
if (add_tag) prompt.tag = ctx.tag;
|
|
691
809
|
fullQueue.push(prompt);
|
|
692
810
|
}
|
|
693
|
-
|
|
694
|
-
let ctxMsgs;
|
|
695
|
-
if (tag_filter !== undefined) {
|
|
696
|
-
ctxMsgs = ctx._msgs.filter(m => {
|
|
697
|
-
if (m.msg.role === 'system') return true;
|
|
698
|
-
if (m.opts.summary) return true;
|
|
699
|
-
if (m.opts.tag === tag_filter) return true;
|
|
700
|
-
return false;
|
|
701
|
-
}).map(m => m.msg);
|
|
702
|
-
} else {
|
|
703
|
-
ctxMsgs = ctx.__msgs;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (add_tag)
|
|
707
|
-
ctxMsgs = ctxMsgs.map(m => Object.assign({tag: ctx.tag}, m));
|
|
708
|
-
|
|
709
|
-
fullQueue.push(...ctxMsgs);
|
|
710
811
|
}
|
|
711
|
-
|
|
712
|
-
// Add this context's prompt and messages
|
|
713
812
|
if (this.prompt) {
|
|
714
813
|
const prompt = {role: 'system', content: this.prompt};
|
|
715
|
-
if (add_tag)
|
|
716
|
-
prompt.tag = this.tag;
|
|
814
|
+
if (add_tag) prompt.tag = this.tag;
|
|
717
815
|
fullQueue.push(prompt);
|
|
718
816
|
}
|
|
719
817
|
|
|
818
|
+
// Layer 2: State summary (if non-empty)
|
|
819
|
+
const stateSummary = this.getStateSummary();
|
|
820
|
+
if (stateSummary)
|
|
821
|
+
fullQueue.push({role: 'system', content: '[State Summary]\n' + stateSummary});
|
|
822
|
+
|
|
823
|
+
// Layer 3: Tool digest (if non-empty)
|
|
824
|
+
if (this.tool_digest.length > 0) {
|
|
825
|
+
const digestText = this.tool_digest.map(entry =>
|
|
826
|
+
`[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
|
|
827
|
+
).join('\n');
|
|
828
|
+
fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Layer 4: Ancestor summaries only (no full ancestor messages)
|
|
832
|
+
for (const ctx of ancestorContexts) {
|
|
833
|
+
const summaries = ctx._msgs
|
|
834
|
+
.filter(m => m.opts.summary)
|
|
835
|
+
.map(m => add_tag ? Object.assign({}, m.msg, {tag: ctx.tag}) : m.msg);
|
|
836
|
+
fullQueue.push(...summaries);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Own messages — filter by tag if requested, then slice to QUEUE_LIMIT
|
|
720
840
|
let my_msgs;
|
|
721
841
|
if (tag_filter !== undefined) {
|
|
722
842
|
my_msgs = this._msgs.filter(m => {
|
|
723
|
-
if (m.msg.role === 'system') return true;
|
|
724
843
|
if (m.opts.summary) return true;
|
|
725
844
|
if (m.opts.tag === tag_filter) return true;
|
|
726
845
|
return false;
|
|
@@ -730,9 +849,9 @@ class Context {
|
|
|
730
849
|
}
|
|
731
850
|
|
|
732
851
|
if (add_tag)
|
|
733
|
-
my_msgs = my_msgs.map(m => Object.assign({tag: this.tag}
|
|
852
|
+
my_msgs = my_msgs.map(m => Object.assign({}, m, {tag: this.tag}));
|
|
734
853
|
|
|
735
|
-
fullQueue.push(...my_msgs);
|
|
854
|
+
fullQueue.push(...this._getQueueSlice(my_msgs, this.QUEUE_LIMIT));
|
|
736
855
|
|
|
737
856
|
return this._validateToolResponses(fullQueue);
|
|
738
857
|
}
|
|
@@ -823,7 +942,6 @@ class Context {
|
|
|
823
942
|
this._processWaitingQueue();
|
|
824
943
|
}
|
|
825
944
|
|
|
826
|
-
await this.summarizeMessages();
|
|
827
945
|
Q = this._createMsgQ(false, o.opts?.tag);
|
|
828
946
|
|
|
829
947
|
// Aggregate functions from hierarchy and merge with message-specific functions
|
|
@@ -898,11 +1016,16 @@ class Context {
|
|
|
898
1016
|
|
|
899
1017
|
for (const { call, isDuplicate } of toolCallsWithResults) {
|
|
900
1018
|
if (!isDuplicate) {
|
|
1019
|
+
const _snap = this.task
|
|
1020
|
+
? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
|
|
901
1021
|
try {
|
|
902
1022
|
const result = await this._executeToolCallWithTimeout(
|
|
903
1023
|
call, o.opts?.handler, o.opts?.timeout);
|
|
904
1024
|
const item = toolCallsWithResults.find(item => item.call.id === call.id);
|
|
905
1025
|
if (item) item.result = result;
|
|
1026
|
+
if (_snap !== null &&
|
|
1027
|
+
_snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
|
|
1028
|
+
this._appendToolDigest(call.function.name, result?.content || '');
|
|
906
1029
|
} finally {
|
|
907
1030
|
this._completeActiveToolCall(call);
|
|
908
1031
|
}
|
|
@@ -971,7 +1094,10 @@ class Context {
|
|
|
971
1094
|
if (content && typeof content !== 'string')
|
|
972
1095
|
content = JSON.stringify(content);
|
|
973
1096
|
else if (!content)
|
|
974
|
-
|
|
1097
|
+
{
|
|
1098
|
+
content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg `
|
|
1099
|
+
+`from the user`;
|
|
1100
|
+
}
|
|
975
1101
|
|
|
976
1102
|
_log('FUNCTION RESULT', call.function.name, call.id, content.substring(0, 50) + '...',
|
|
977
1103
|
functions ? 'with functions' : 'no functions');
|
package/index.js
CHANGED
|
@@ -9,20 +9,44 @@
|
|
|
9
9
|
* - Optional conversation contexts attached to tasks
|
|
10
10
|
* - Hierarchical message aggregation with function collection
|
|
11
11
|
* - Full tool_calls support with depth control
|
|
12
|
+
* - Storage persistence (Redis cache + optional DB backend)
|
|
12
13
|
*
|
|
13
14
|
* Main Components:
|
|
14
15
|
* - Itask: Base task class for all tasks (supports states, cancellation, promises)
|
|
15
16
|
* - Context: Conversation context with message handling and tool calls
|
|
16
17
|
* - Sid: Session root task (extends Itask, always has a context)
|
|
18
|
+
* - Store: Storage abstraction layer (Redis + optional backends like DynamoDB)
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
const Itask = require('./itask.js');
|
|
20
22
|
const { Context, createContext } = require('./context.js');
|
|
21
23
|
const { Sid, createSid } = require('./sid.js');
|
|
24
|
+
const { Store, DynamoBackend } = require('./store.js');
|
|
22
25
|
|
|
23
26
|
// Wire up Context class reference in Itask to avoid circular dependency
|
|
24
27
|
Itask.Context = Context;
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Initialize Saico with storage configuration.
|
|
31
|
+
* Sets up the Store singleton and optionally initializes Redis.
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} config - Configuration options
|
|
34
|
+
* @param {boolean} config.redis - Whether to initialize Redis
|
|
35
|
+
* @param {Object} config.dynamodb - DynamoDB backend config {table, aws}
|
|
36
|
+
* @returns {Store} The initialized Store instance
|
|
37
|
+
*/
|
|
38
|
+
async function init(config = {}) {
|
|
39
|
+
const store = Store.init(config);
|
|
40
|
+
|
|
41
|
+
if (config.redis) {
|
|
42
|
+
const redis = require('./redis.js');
|
|
43
|
+
await redis.init();
|
|
44
|
+
store.setRedis(redis.rclient);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return store;
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
/**
|
|
27
51
|
* Create a new task with optional context.
|
|
28
52
|
*
|
|
@@ -35,6 +59,7 @@ Itask.Context = Context;
|
|
|
35
59
|
* @param {Object} opt.bind - Bind context for state functions
|
|
36
60
|
* @param {Itask} opt.spawn_parent - Parent task to spawn under
|
|
37
61
|
* @param {boolean} opt.async - If true, don't auto-run
|
|
62
|
+
* @param {Object} opt.store - Store instance for persistence
|
|
38
63
|
* @param {Array} states - Array of state functions
|
|
39
64
|
* @returns {Itask} The created task
|
|
40
65
|
*/
|
|
@@ -42,6 +67,9 @@ function createTask(opt, states = []) {
|
|
|
42
67
|
if (typeof opt === 'string')
|
|
43
68
|
opt = { name: opt };
|
|
44
69
|
|
|
70
|
+
if (!opt.store)
|
|
71
|
+
opt.store = Store.instance;
|
|
72
|
+
|
|
45
73
|
const task = new Itask(opt, states);
|
|
46
74
|
|
|
47
75
|
// Auto-create context if prompt is provided
|
|
@@ -95,7 +123,8 @@ function createQ(prompt, parent, tag, token_limit, msgs, tool_handler, config =
|
|
|
95
123
|
const childTask = new Itask({
|
|
96
124
|
name: tag || 'child-context',
|
|
97
125
|
async: true,
|
|
98
|
-
spawn_parent: parent.task
|
|
126
|
+
spawn_parent: parent.task,
|
|
127
|
+
store: Store.instance
|
|
99
128
|
}, []);
|
|
100
129
|
context.setTask(childTask);
|
|
101
130
|
childTask.setContext(context);
|
|
@@ -110,6 +139,11 @@ module.exports = {
|
|
|
110
139
|
Itask,
|
|
111
140
|
Context,
|
|
112
141
|
Sid,
|
|
142
|
+
Store,
|
|
143
|
+
DynamoBackend,
|
|
144
|
+
|
|
145
|
+
// Initialization
|
|
146
|
+
init,
|
|
113
147
|
|
|
114
148
|
// Factory functions
|
|
115
149
|
createTask,
|
package/itask.js
CHANGED
|
@@ -18,6 +18,7 @@ const assert = require('assert');
|
|
|
18
18
|
const EventEmitter = require('events');
|
|
19
19
|
const crypto = require('crypto');
|
|
20
20
|
const util = require('./util.js');
|
|
21
|
+
const { Store } = require('./store.js');
|
|
21
22
|
|
|
22
23
|
const { _log, lerr , _ldbg, daysSince, minSince, shallowEqual, filterArray, logEvent } = util;
|
|
23
24
|
|
|
@@ -102,6 +103,10 @@ function Itask(opt, states){
|
|
|
102
103
|
this.context = null;
|
|
103
104
|
this._contextConfig = opt.contextConfig || {};
|
|
104
105
|
|
|
106
|
+
// Storage persistence
|
|
107
|
+
this.context_id = opt.context_id || null;
|
|
108
|
+
this._store = opt.store || Store.instance || null;
|
|
109
|
+
|
|
105
110
|
// Store options for context creation (prompt, functions, etc.)
|
|
106
111
|
this.prompt = opt.prompt;
|
|
107
112
|
this.functions = opt.functions;
|
|
@@ -370,6 +375,15 @@ Itask.prototype.spawn = function spawn(child){
|
|
|
370
375
|
Itask.root.delete(child);
|
|
371
376
|
child._root_registered = false;
|
|
372
377
|
}
|
|
378
|
+
// Auto-wrap with redis observable for live state persistence
|
|
379
|
+
if (child.context_id) {
|
|
380
|
+
try {
|
|
381
|
+
const redis = require('./redis.js');
|
|
382
|
+
if (redis.rclient) {
|
|
383
|
+
redis.createObservableForRedis('saico:' + child.context_id, child);
|
|
384
|
+
}
|
|
385
|
+
} catch (e) { /* redis not available */ }
|
|
386
|
+
}
|
|
373
387
|
// ensure async-created children begin execution
|
|
374
388
|
if (!child.running && !child._completed){
|
|
375
389
|
process.nextTick(() => {
|
|
@@ -578,6 +592,11 @@ Itask.ps = function ps(){
|
|
|
578
592
|
};
|
|
579
593
|
|
|
580
594
|
/* ---------- context management ---------- */
|
|
595
|
+
// [BACKEND] explanation text appended to context prompts
|
|
596
|
+
Itask.BACKEND_EXPLANATION = '\nNote: Messages prefixed with [BACKEND] are from the backend ' +
|
|
597
|
+
'server, not the user. They contain server instructions, data updates, or system context. ' +
|
|
598
|
+
'Treat them as authoritative system-level information.';
|
|
599
|
+
|
|
581
600
|
// Get the context for this task, optionally creating one if needed
|
|
582
601
|
Itask.prototype.getContext = function getContext(createIfMissing = false){
|
|
583
602
|
if (this.context)
|
|
@@ -585,7 +604,9 @@ Itask.prototype.getContext = function getContext(createIfMissing = false){
|
|
|
585
604
|
if (createIfMissing && this.prompt){
|
|
586
605
|
// Lazy context creation - requires Context class to be set
|
|
587
606
|
if (Itask.Context){
|
|
588
|
-
|
|
607
|
+
const augmentedPrompt = this.prompt + Itask.BACKEND_EXPLANATION;
|
|
608
|
+
this.context = new Itask.Context(augmentedPrompt, this, this._contextConfig);
|
|
609
|
+
this.setContext(this.context);
|
|
589
610
|
return this.context;
|
|
590
611
|
}
|
|
591
612
|
}
|
|
@@ -595,8 +616,20 @@ Itask.prototype.getContext = function getContext(createIfMissing = false){
|
|
|
595
616
|
// Set context for this task
|
|
596
617
|
Itask.prototype.setContext = function setContext(context){
|
|
597
618
|
this.context = context;
|
|
598
|
-
|
|
599
|
-
|
|
619
|
+
// Generate context_id if not already set
|
|
620
|
+
if (!this.context_id) {
|
|
621
|
+
if (this._store)
|
|
622
|
+
this.context_id = this._store.generateId();
|
|
623
|
+
else if (Store.instance)
|
|
624
|
+
this.context_id = Store.instance.generateId();
|
|
625
|
+
else
|
|
626
|
+
this.context_id = makeId(16);
|
|
627
|
+
}
|
|
628
|
+
if (context) {
|
|
629
|
+
context.tag = this.context_id;
|
|
630
|
+
if (typeof context.setTask === 'function')
|
|
631
|
+
context.setTask(this);
|
|
632
|
+
}
|
|
600
633
|
return this;
|
|
601
634
|
};
|
|
602
635
|
|
|
@@ -623,9 +656,10 @@ Itask.prototype.findContext = function findContext(){
|
|
|
623
656
|
return null;
|
|
624
657
|
};
|
|
625
658
|
|
|
626
|
-
// Send a message using the context hierarchy
|
|
627
|
-
//
|
|
628
|
-
|
|
659
|
+
// Send a backend message using the context hierarchy
|
|
660
|
+
// New signature: sendMessage(content, functions, opts)
|
|
661
|
+
// Always sends as role='user' with '[BACKEND] ' prefix
|
|
662
|
+
Itask.prototype.sendMessage = async function sendMessage(content, functions, opts){
|
|
629
663
|
// First try our own context
|
|
630
664
|
let ctx = this.getContext();
|
|
631
665
|
if (!ctx){
|
|
@@ -635,9 +669,21 @@ Itask.prototype.sendMessage = async function sendMessage(role, content, function
|
|
|
635
669
|
if (!ctx){
|
|
636
670
|
throw new Error('No context available in task hierarchy to send message');
|
|
637
671
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
672
|
+
opts = Object.assign({}, opts, { tag: this.context_id });
|
|
673
|
+
return ctx.sendMessage('user', '[BACKEND] ' + content, functions, opts);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Receive a user chat message (no [BACKEND] prefix)
|
|
677
|
+
Itask.prototype.recvChatMessage = async function recvChatMessage(content, opts){
|
|
678
|
+
let ctx = this.getContext();
|
|
679
|
+
if (!ctx){
|
|
680
|
+
ctx = this.findContext();
|
|
681
|
+
}
|
|
682
|
+
if (!ctx){
|
|
683
|
+
throw new Error('No context available in task hierarchy to receive message');
|
|
684
|
+
}
|
|
685
|
+
opts = Object.assign({}, opts, { tag: this.context_id });
|
|
686
|
+
return ctx.sendMessage('user', content, null, opts);
|
|
641
687
|
};
|
|
642
688
|
|
|
643
689
|
// Aggregate functions from all contexts in the hierarchy
|
|
@@ -658,6 +704,45 @@ Itask.prototype.getHierarchyFunctions = function getHierarchyFunctions(){
|
|
|
658
704
|
Itask.prototype.closeContext = async function closeContext(){
|
|
659
705
|
if (!this.context)
|
|
660
706
|
return;
|
|
707
|
+
|
|
708
|
+
// Clean tool call messages tagged with this context_id
|
|
709
|
+
if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
|
|
710
|
+
this.context.cleanToolCallsByTag(this.context_id);
|
|
711
|
+
|
|
712
|
+
// Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
|
|
713
|
+
const cleanedMsgs = this.context._msgs.filter(m => {
|
|
714
|
+
if (m.msg.tool_calls)
|
|
715
|
+
return false;
|
|
716
|
+
if (m.msg.role === 'tool')
|
|
717
|
+
return false;
|
|
718
|
+
if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]'))
|
|
719
|
+
return false;
|
|
720
|
+
return true;
|
|
721
|
+
}).map(m => m.msg);
|
|
722
|
+
|
|
723
|
+
// Trim to last QUEUE_LIMIT before persisting
|
|
724
|
+
const queueLimit = this.context.QUEUE_LIMIT || 30;
|
|
725
|
+
const trimmedMsgs = cleanedMsgs.length > queueLimit
|
|
726
|
+
? cleanedMsgs.slice(-queueLimit)
|
|
727
|
+
: cleanedMsgs;
|
|
728
|
+
|
|
729
|
+
if (trimmedMsgs.length > 0) {
|
|
730
|
+
const chat_history = await util.compressMessages(trimmedMsgs);
|
|
731
|
+
this.context.chat_history = chat_history;
|
|
732
|
+
|
|
733
|
+
// Persist to store
|
|
734
|
+
const store = this._store || Store.instance;
|
|
735
|
+
if (store && this.context_id) {
|
|
736
|
+
await store.save(this.context_id, {
|
|
737
|
+
chat_history,
|
|
738
|
+
tool_digest: this.context.tool_digest || [],
|
|
739
|
+
prompt: this.context.prompt,
|
|
740
|
+
tag: this.context.tag,
|
|
741
|
+
tm_closed: Date.now()
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
661
746
|
await this.context.close();
|
|
662
747
|
};
|
|
663
748
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saico",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"openai.js",
|
|
21
21
|
"util.js",
|
|
22
22
|
"redis.js",
|
|
23
|
+
"store.js",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
25
26
|
],
|
|
@@ -43,7 +44,16 @@
|
|
|
43
44
|
"test": "NODE_ENV=test mocha --exit 'test/**/*.test.js'",
|
|
44
45
|
"start": "node server.js"
|
|
45
46
|
},
|
|
46
|
-
"keywords": [
|
|
47
|
+
"keywords": [
|
|
48
|
+
"ai",
|
|
49
|
+
"conversation",
|
|
50
|
+
"orchestrator",
|
|
51
|
+
"task",
|
|
52
|
+
"hierarchy",
|
|
53
|
+
"openai",
|
|
54
|
+
"chatgpt",
|
|
55
|
+
"llm"
|
|
56
|
+
],
|
|
47
57
|
"author": "wanderli-ai",
|
|
48
58
|
"license": "ISC"
|
|
49
59
|
}
|
package/sid.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const Itask = require('./itask.js');
|
|
4
4
|
const { Context, createContext } = require('./context.js');
|
|
5
|
+
const { Store } = require('./store.js');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Sid - Session/User Context root task.
|
|
@@ -25,6 +26,7 @@ class Sid extends Itask {
|
|
|
25
26
|
...opt,
|
|
26
27
|
name,
|
|
27
28
|
prompt,
|
|
29
|
+
store: opt.store || Store.instance || null,
|
|
28
30
|
async: true // We'll manage running ourselves
|
|
29
31
|
}, states);
|
|
30
32
|
|
|
@@ -39,16 +41,23 @@ class Sid extends Itask {
|
|
|
39
41
|
...opt.sessionConfig
|
|
40
42
|
};
|
|
41
43
|
|
|
44
|
+
// Generate context_id if not already set by parent constructor
|
|
45
|
+
if (!this.context_id) {
|
|
46
|
+
const store = this._store || Store.instance;
|
|
47
|
+
this.context_id = store ? store.generateId() : require('crypto').randomBytes(8).toString('hex');
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
// Always create a context for Sid (root session task)
|
|
43
51
|
const contextConfig = {
|
|
44
|
-
tag:
|
|
52
|
+
tag: this.context_id,
|
|
45
53
|
token_limit: this.sessionConfig.token_limit,
|
|
46
54
|
max_depth: this.sessionConfig.max_depth,
|
|
47
55
|
max_tool_repetition: this.sessionConfig.max_tool_repetition,
|
|
48
56
|
tool_handler: opt.tool_handler,
|
|
49
57
|
functions: opt.functions,
|
|
50
58
|
sequential_mode: opt.sequential_mode,
|
|
51
|
-
msgs: opt.msgs
|
|
59
|
+
msgs: opt.msgs,
|
|
60
|
+
chat_history: opt.chat_history
|
|
52
61
|
};
|
|
53
62
|
|
|
54
63
|
this.context = new Context(prompt, this, contextConfig);
|
|
@@ -61,9 +70,17 @@ class Sid extends Itask {
|
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
// Override sendMessage
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
// Override sendMessage — new signature: sendMessage(content, functions, opts)
|
|
74
|
+
// Always sends as role='user' with '[BACKEND] ' prefix
|
|
75
|
+
async sendMessage(content, functions, opts) {
|
|
76
|
+
opts = Object.assign({}, opts, { tag: this.context_id });
|
|
77
|
+
return this.context.sendMessage('user', '[BACKEND] ' + content, functions || this.functions, opts);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Receive a user chat message (no [BACKEND] prefix)
|
|
81
|
+
async recvChatMessage(content, opts) {
|
|
82
|
+
opts = Object.assign({}, opts, { tag: this.context_id });
|
|
83
|
+
return this.context.sendMessage('user', content, null, opts);
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
// Serialize the session for persistence
|
|
@@ -72,12 +89,15 @@ class Sid extends Itask {
|
|
|
72
89
|
id: this.id,
|
|
73
90
|
name: this.name,
|
|
74
91
|
prompt: this.prompt,
|
|
92
|
+
context_id: this.context_id,
|
|
75
93
|
userData: this.userData,
|
|
76
94
|
sessionConfig: this.sessionConfig,
|
|
77
95
|
context: {
|
|
78
96
|
tag: this.context.tag,
|
|
79
97
|
msgs: this.context._msgs,
|
|
80
|
-
functions: this.context.functions
|
|
98
|
+
functions: this.context.functions,
|
|
99
|
+
chat_history: this.context.chat_history,
|
|
100
|
+
tool_digest: this.context.tool_digest
|
|
81
101
|
},
|
|
82
102
|
tm_create: this.tm_create
|
|
83
103
|
});
|
|
@@ -90,11 +110,14 @@ class Sid extends Itask {
|
|
|
90
110
|
const sid = new Sid({
|
|
91
111
|
name: parsed.name,
|
|
92
112
|
prompt: parsed.prompt,
|
|
113
|
+
context_id: parsed.context_id,
|
|
93
114
|
userData: parsed.userData,
|
|
94
115
|
sessionConfig: parsed.sessionConfig,
|
|
95
116
|
tag: parsed.context?.tag,
|
|
96
117
|
tool_handler: opt.tool_handler,
|
|
97
118
|
functions: opt.functions || parsed.context?.functions,
|
|
119
|
+
chat_history: parsed.context?.chat_history,
|
|
120
|
+
store: opt.store,
|
|
98
121
|
async: true, // Don't auto-run states
|
|
99
122
|
...opt
|
|
100
123
|
}, opt.states || []);
|
|
@@ -108,6 +131,16 @@ class Sid extends Itask {
|
|
|
108
131
|
sid.context._msgs = parsed.context.msgs;
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
// Restore tool_digest
|
|
135
|
+
if (Array.isArray(parsed.context?.tool_digest)) {
|
|
136
|
+
sid.context.tool_digest = parsed.context.tool_digest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Load history from store if available
|
|
140
|
+
if (opt.store && parsed.context?.chat_history) {
|
|
141
|
+
sid.context.chat_history = parsed.context.chat_history;
|
|
142
|
+
}
|
|
143
|
+
|
|
111
144
|
return sid;
|
|
112
145
|
}
|
|
113
146
|
|
|
@@ -119,6 +152,7 @@ class Sid extends Itask {
|
|
|
119
152
|
const childTask = new Itask({
|
|
120
153
|
...opt,
|
|
121
154
|
spawn_parent: this,
|
|
155
|
+
store: this._store,
|
|
122
156
|
async: true
|
|
123
157
|
}, states);
|
|
124
158
|
|
|
@@ -150,6 +184,7 @@ class Sid extends Itask {
|
|
|
150
184
|
const childTask = new Itask({
|
|
151
185
|
...opt,
|
|
152
186
|
spawn_parent: this,
|
|
187
|
+
store: this._store,
|
|
153
188
|
async: true
|
|
154
189
|
}, states);
|
|
155
190
|
|
package/store.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
let _instance = null;
|
|
6
|
+
|
|
7
|
+
class DynamoBackend {
|
|
8
|
+
constructor({ table, aws }) {
|
|
9
|
+
this.table = table;
|
|
10
|
+
this.aws = aws;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async save(id, data) {
|
|
14
|
+
await this.aws.dynamoPutItem(this.table, {
|
|
15
|
+
id,
|
|
16
|
+
data: typeof data === 'string' ? data : JSON.stringify(data),
|
|
17
|
+
updated_at: Date.now()
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async load(id) {
|
|
22
|
+
const item = await this.aws.dynamoGetItem(this.table, 'id', id);
|
|
23
|
+
if (!item)
|
|
24
|
+
return null;
|
|
25
|
+
const data = item.data;
|
|
26
|
+
if (typeof data === 'string') {
|
|
27
|
+
try { return JSON.parse(data); }
|
|
28
|
+
catch (e) { return data; }
|
|
29
|
+
}
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async delete(id) {
|
|
34
|
+
await this.aws.dynamoDeleteItem(this.table, 'id', id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class Store {
|
|
39
|
+
constructor(config = {}) {
|
|
40
|
+
this._redis = null;
|
|
41
|
+
this._backends = {};
|
|
42
|
+
this._config = config;
|
|
43
|
+
|
|
44
|
+
if (config.dynamodb) {
|
|
45
|
+
this._backends.dynamodb = new DynamoBackend(config.dynamodb);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static get instance() {
|
|
50
|
+
return _instance;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static set instance(val) {
|
|
54
|
+
_instance = val;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
static init(config = {}) {
|
|
58
|
+
_instance = new Store(config);
|
|
59
|
+
// If redis module provided or redis is already initialized, grab the client
|
|
60
|
+
const redis = require('./redis.js');
|
|
61
|
+
if (redis.rclient)
|
|
62
|
+
_instance._redis = redis.rclient;
|
|
63
|
+
return _instance;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setRedis(rclient) {
|
|
67
|
+
this._redis = rclient;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
addBackend(name, backend) {
|
|
71
|
+
this._backends[name] = backend;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
generateId() {
|
|
75
|
+
return crypto.randomBytes(8).toString('hex');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async save(id, data) {
|
|
79
|
+
const key = 'saico:' + id;
|
|
80
|
+
const serialized = typeof data === 'string' ? data : JSON.stringify(data);
|
|
81
|
+
|
|
82
|
+
// Always save to Redis if available
|
|
83
|
+
if (this._redis) {
|
|
84
|
+
try {
|
|
85
|
+
await this._redis.set(key, serialized);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error('Store: Redis save error:', e.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Save to all configured backends
|
|
92
|
+
for (const [name, backend] of Object.entries(this._backends)) {
|
|
93
|
+
try {
|
|
94
|
+
await backend.save(id, data);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`Store: ${name} backend save error:`, e.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(id) {
|
|
102
|
+
const key = 'saico:' + id;
|
|
103
|
+
|
|
104
|
+
// Try Redis first
|
|
105
|
+
if (this._redis) {
|
|
106
|
+
try {
|
|
107
|
+
const cached = await this._redis.get(key);
|
|
108
|
+
if (cached) {
|
|
109
|
+
try { return JSON.parse(cached); }
|
|
110
|
+
catch (e) { return cached; }
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error('Store: Redis load error:', e.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fall back to backends
|
|
118
|
+
for (const [name, backend] of Object.entries(this._backends)) {
|
|
119
|
+
try {
|
|
120
|
+
const data = await backend.load(id);
|
|
121
|
+
if (data) {
|
|
122
|
+
// Cache to Redis for next time
|
|
123
|
+
if (this._redis) {
|
|
124
|
+
try {
|
|
125
|
+
const serialized = typeof data === 'string'
|
|
126
|
+
? data : JSON.stringify(data);
|
|
127
|
+
await this._redis.set(key, serialized);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
console.error('Store: Redis cache-back error:', e.message);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return data;
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error(`Store: ${name} backend load error:`, e.message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async delete(id) {
|
|
143
|
+
const key = 'saico:' + id;
|
|
144
|
+
|
|
145
|
+
if (this._redis) {
|
|
146
|
+
try {
|
|
147
|
+
await this._redis.del(key);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error('Store: Redis delete error:', e.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [name, backend] of Object.entries(this._backends)) {
|
|
154
|
+
try {
|
|
155
|
+
await backend.delete(id);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(`Store: ${name} backend delete error:`, e.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { Store, DynamoBackend };
|
package/util.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const is_mocha = process.env.NODE_ENV == 'test';
|
|
2
2
|
const tiktoken = require('tiktoken');
|
|
3
|
+
const zlib = require('zlib');
|
|
4
|
+
const { promisify } = require('util');
|
|
5
|
+
const gzip = promisify(zlib.gzip);
|
|
6
|
+
const gunzip = promisify(zlib.gunzip);
|
|
3
7
|
|
|
4
8
|
const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
|
5
9
|
|
|
@@ -58,6 +62,8 @@ const lerr = _lerr;
|
|
|
58
62
|
|
|
59
63
|
module.exports = {
|
|
60
64
|
countTokens,
|
|
65
|
+
compressMessages,
|
|
66
|
+
decompressMessages,
|
|
61
67
|
is_mocha,
|
|
62
68
|
_log,
|
|
63
69
|
_lerr,
|
|
@@ -70,6 +76,34 @@ module.exports = {
|
|
|
70
76
|
logEvent,
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
async function compressMessages(messages) {
|
|
80
|
+
const json = JSON.stringify(messages);
|
|
81
|
+
const compressed = await gzip(Buffer.from(json, 'utf8'));
|
|
82
|
+
return compressed.toString('base64');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function decompressMessages(data) {
|
|
86
|
+
if (Array.isArray(data))
|
|
87
|
+
return data;
|
|
88
|
+
if (typeof data === 'string') {
|
|
89
|
+
// Try JSON parse first (plain JSON string)
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(data);
|
|
92
|
+
if (Array.isArray(parsed))
|
|
93
|
+
return parsed;
|
|
94
|
+
} catch (e) { /* not plain JSON, try base64/gzip */ }
|
|
95
|
+
// Try base64 gzip
|
|
96
|
+
try {
|
|
97
|
+
const buf = Buffer.from(data, 'base64');
|
|
98
|
+
const decompressed = await gunzip(buf);
|
|
99
|
+
return JSON.parse(decompressed.toString('utf8'));
|
|
100
|
+
} catch (e) {
|
|
101
|
+
throw new Error('decompressMessages: unable to decompress data: ' + e.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw new Error('decompressMessages: unsupported data type: ' + typeof data);
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
function countTokens(messages, model = "gpt-4o") {
|
|
74
108
|
// Load the encoding for the specified model
|
|
75
109
|
const encoding = tiktoken.encoding_for_model(model);
|