saico 2.7.1 → 2.8.1
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/README.md +20 -18
- package/index.js +3 -3
- package/msgs.js +74 -211
- package/package.json +1 -1
- package/saico.js +130 -109
package/README.md
CHANGED
|
@@ -156,29 +156,28 @@ When a Saico's context is not the deepest active one, its last 5 user/assistant
|
|
|
156
156
|
### Spawning Child Saico Instances
|
|
157
157
|
|
|
158
158
|
```js
|
|
159
|
-
// Child with its own conversation context
|
|
159
|
+
// Child with its own conversation context (auto-activated by spawn)
|
|
160
160
|
const child = new Saico({
|
|
161
161
|
name: 'subtask',
|
|
162
162
|
prompt: 'Handle this specific sub-task',
|
|
163
|
+
createQ: true,
|
|
163
164
|
functions: [/* child-specific tools */],
|
|
164
165
|
});
|
|
165
|
-
child.activate({ createQ: true });
|
|
166
166
|
agent.spawn(child);
|
|
167
167
|
await child.sendMessage('Working on subtask...');
|
|
168
168
|
|
|
169
169
|
// Child without context (uses parent's via findContext())
|
|
170
170
|
const simple = new Saico({ name: 'simple' });
|
|
171
|
-
simple.activate();
|
|
172
171
|
agent.spawn(simple);
|
|
173
172
|
await simple.sendMessage('Quick operation');
|
|
174
173
|
|
|
175
174
|
// spawnAndRun: spawn + schedule child task to run on nextTick
|
|
176
175
|
const runner = new Saico({ name: 'runner' });
|
|
177
|
-
runner.
|
|
176
|
+
runner.states = [async function() { return await this.sendMessage('Go'); }];
|
|
178
177
|
agent.spawnAndRun(runner);
|
|
179
178
|
```
|
|
180
179
|
|
|
181
|
-
|
|
180
|
+
Parent must be activated before calling `spawn()` or `spawnAndRun()`. Children are auto-activated if needed.
|
|
182
181
|
|
|
183
182
|
### Deactivation and Message Bubbling
|
|
184
183
|
|
|
@@ -258,7 +257,10 @@ agent.getSessionInfo();
|
|
|
258
257
|
// userData, uptime
|
|
259
258
|
// }
|
|
260
259
|
|
|
261
|
-
await agent.closeSession(); //
|
|
260
|
+
await agent.closeSession(); // Saves full state to Store, cancels task
|
|
261
|
+
|
|
262
|
+
// Restore from Store
|
|
263
|
+
const restored = await Saico.rehydrate(agent.id, { store });
|
|
262
264
|
```
|
|
263
265
|
|
|
264
266
|
## Database Access
|
|
@@ -299,16 +301,16 @@ class MyAgent extends Saico {
|
|
|
299
301
|
## Serialization
|
|
300
302
|
|
|
301
303
|
```js
|
|
302
|
-
//
|
|
304
|
+
// In-memory snapshot (raw msgs, used by Redis proxy)
|
|
303
305
|
const json = agent.serialize();
|
|
306
|
+
const restored = Saico.deserialize(json);
|
|
304
307
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
});
|
|
308
|
+
// Durable persistence (compressed msgs, saved to Store)
|
|
309
|
+
await agent.closeSession();
|
|
310
|
+
const restored2 = await Saico.rehydrate(agent.id, { store });
|
|
309
311
|
```
|
|
310
312
|
|
|
311
|
-
|
|
313
|
+
`serialize()` includes: id, name, prompt, userData, sessionConfig, tm_create, isolate, and full context state (raw messages, tool_digest). `closeSession()` saves the same shape but with compressed messages for durable storage.
|
|
312
314
|
|
|
313
315
|
## Redis Persistence
|
|
314
316
|
|
|
@@ -328,7 +330,7 @@ Properties prefixed with `_` are internal and not persisted.
|
|
|
328
330
|
|
|
329
331
|
## Tool Implementation (TOOL_ methods)
|
|
330
332
|
|
|
331
|
-
Define tool implementations as `TOOL_`-prefixed methods on your Saico subclass. When the LLM returns a tool call,
|
|
333
|
+
Define tool implementations as `TOOL_`-prefixed methods on your Saico subclass. When the LLM returns a tool call, Saico automatically searches the task hierarchy (current → up parents → down children) to find and invoke the matching method with parsed arguments.
|
|
332
334
|
|
|
333
335
|
```js
|
|
334
336
|
class MyAgent extends Saico {
|
|
@@ -357,13 +359,13 @@ Return a string or `{ content: string, functions?: [] }`.
|
|
|
357
359
|
|
|
358
360
|
## Low-Level API
|
|
359
361
|
|
|
360
|
-
For cases where you need a standalone
|
|
362
|
+
For cases where you need a standalone message queue without the Saico master class:
|
|
361
363
|
|
|
362
364
|
```js
|
|
363
|
-
const {
|
|
365
|
+
const { createMsgs } = require('saico');
|
|
364
366
|
|
|
365
|
-
// Standalone
|
|
366
|
-
const ctx =
|
|
367
|
+
// Standalone message queue
|
|
368
|
+
const ctx = createMsgs('System prompt', { tag: 'my-tag', token_limit: 4000 });
|
|
367
369
|
const reply = await ctx.sendMessage('user', 'Hello', functions);
|
|
368
370
|
```
|
|
369
371
|
|
|
@@ -388,7 +390,7 @@ saico/
|
|
|
388
390
|
npm test
|
|
389
391
|
```
|
|
390
392
|
|
|
391
|
-
|
|
393
|
+
290 tests covering Saico lifecycle, context ownership, spawn/spawnAndRun, task hierarchy, message handling, tool calls, DB adapters, serialization, persistence (closeSession/rehydrate), and integration flows.
|
|
392
394
|
|
|
393
395
|
## Requirements
|
|
394
396
|
|
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const Itask = require('./itask.js');
|
|
4
|
-
const {
|
|
4
|
+
const { Msgs, createMsgs } = require('./msgs.js');
|
|
5
5
|
const { Store, DynamoBackend } = require('./store.js');
|
|
6
6
|
const { Saico } = require('./saico.js');
|
|
7
7
|
const { DynamoDBAdapter } = require('./dynamo.js');
|
|
@@ -34,7 +34,7 @@ module.exports = {
|
|
|
34
34
|
|
|
35
35
|
// Core classes
|
|
36
36
|
Itask,
|
|
37
|
-
|
|
37
|
+
Msgs,
|
|
38
38
|
Store,
|
|
39
39
|
DynamoBackend,
|
|
40
40
|
|
|
@@ -42,7 +42,7 @@ module.exports = {
|
|
|
42
42
|
init,
|
|
43
43
|
|
|
44
44
|
// Factory
|
|
45
|
-
|
|
45
|
+
createMsgs,
|
|
46
46
|
|
|
47
47
|
// Utilities (re-export from util.js)
|
|
48
48
|
util: require('./util.js'),
|
package/msgs.js
CHANGED
|
@@ -8,23 +8,17 @@ const { _log, _lerr, _ldbg } = util;
|
|
|
8
8
|
const debug = 0;
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Key differences from the old Messages class:
|
|
14
|
-
* - Uses task hierarchy instead of parent/child messages
|
|
15
|
-
* - task reference replaces parent reference
|
|
16
|
-
* - getMsgContext() traverses task hierarchy
|
|
17
|
-
* - _createMsgQ() aggregates from task ancestors
|
|
11
|
+
* Msgs - Pure message queue with tool call handling, summarization, and LLM communication.
|
|
12
|
+
* Saico sets callback hooks after construction to wire in hierarchy access.
|
|
18
13
|
*/
|
|
19
|
-
class
|
|
20
|
-
constructor(prompt,
|
|
14
|
+
class Msgs {
|
|
15
|
+
constructor(prompt, config = {}) {
|
|
21
16
|
this.prompt = prompt;
|
|
22
|
-
this.task = task; // Reference to owning Itask (replaces parent)
|
|
23
17
|
this.tag = config.tag || crypto.randomBytes(4).toString('hex');
|
|
24
18
|
this.token_limit = config.token_limit || 1000000000;
|
|
25
19
|
this.lower_limit = this.token_limit * 0.85;
|
|
26
20
|
this.upper_limit = this.token_limit * 0.98;
|
|
27
|
-
this.functions = config.functions ||
|
|
21
|
+
this.functions = config.functions || null;
|
|
28
22
|
|
|
29
23
|
// Recursive depth and repetition control
|
|
30
24
|
this.max_depth = config.max_depth || 5;
|
|
@@ -33,9 +27,6 @@ class Context {
|
|
|
33
27
|
this._deferred_tool_calls = [];
|
|
34
28
|
this._tool_call_sequence = [];
|
|
35
29
|
|
|
36
|
-
// Chat history persistence
|
|
37
|
-
this.chat_history = config.chat_history || null;
|
|
38
|
-
|
|
39
30
|
this._msgs = [];
|
|
40
31
|
this._waitingQueue = [];
|
|
41
32
|
this._active_tool_calls = new Map();
|
|
@@ -53,17 +44,60 @@ class Context {
|
|
|
53
44
|
// Tool digest — persistent history of tool calls that mutated task state
|
|
54
45
|
this.tool_digest = config.tool_digest || [];
|
|
55
46
|
|
|
56
|
-
//
|
|
47
|
+
// Callback hooks — set by Saico after construction
|
|
48
|
+
this._findToolImpl = null; // (toolName) => { saico, methodName } | null
|
|
49
|
+
this._getSnapshot = null; // () => serializable snapshot for dirty detection
|
|
50
|
+
|
|
51
|
+
// Initialize messages: explicit msgs take priority over chat_history
|
|
52
|
+
this._chat_history = config.chat_history || null;
|
|
57
53
|
(config.msgs || []).forEach(m => this.push(m));
|
|
58
54
|
|
|
59
|
-
_log('created
|
|
55
|
+
_log('created Msgs for tag', this.tag);
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Decompress _chat_history into _msgs. Call after construction when
|
|
60
|
+
* restoring from persisted state. No-op if chat_history is absent or
|
|
61
|
+
* _msgs were already provided via config.msgs.
|
|
62
|
+
*/
|
|
63
|
+
async initHistory() {
|
|
64
|
+
if (!this._chat_history || this._msgs.length > 0)
|
|
65
|
+
return;
|
|
66
|
+
const messages = await util.decompressMessages(this._chat_history);
|
|
67
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
68
|
+
return;
|
|
69
|
+
for (const m of messages) {
|
|
70
|
+
this._msgs.push({
|
|
71
|
+
msg: m,
|
|
72
|
+
opts: {},
|
|
73
|
+
msgid: crypto.randomBytes(2).toString('hex'),
|
|
74
|
+
replied: 1,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prepare the message Q for storage. Filters out tool calls, tool
|
|
81
|
+
* responses, and [BACKEND] messages, trims to QUEUE_LIMIT, compresses.
|
|
82
|
+
* Returns { chat_history, tool_digest }. Does NOT mutate _msgs.
|
|
83
|
+
*/
|
|
84
|
+
async prepareForStorage() {
|
|
85
|
+
const cleaned = this._msgs.filter(m => {
|
|
86
|
+
if (m.msg.tool_calls) return false;
|
|
87
|
+
if (m.msg.role === 'tool') return false;
|
|
88
|
+
if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}).map(m => m.msg);
|
|
91
|
+
|
|
92
|
+
const trimmed = cleaned.length > this.QUEUE_LIMIT
|
|
93
|
+
? cleaned.slice(-this.QUEUE_LIMIT)
|
|
94
|
+
: cleaned;
|
|
95
|
+
|
|
96
|
+
const chat_history = trimmed.length > 0
|
|
97
|
+
? await util.compressMessages(trimmed)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return { chat_history, tool_digest: this.tool_digest || [] };
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
// Snapshot all public (non-underscore) task properties for dirty detection.
|
|
@@ -92,32 +126,6 @@ class Context {
|
|
|
92
126
|
this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
// Get the parent context by traversing task hierarchy (via Saico)
|
|
96
|
-
getParentContext() {
|
|
97
|
-
if (!this.task || !this.task.parent)
|
|
98
|
-
return null;
|
|
99
|
-
let task = this.task.parent;
|
|
100
|
-
while (task) {
|
|
101
|
-
if (task._saico?.context) return task._saico.context;
|
|
102
|
-
task = task.parent;
|
|
103
|
-
}
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Get all ancestor contexts via task hierarchy (via Saico)
|
|
108
|
-
getAncestorContexts() {
|
|
109
|
-
if (!this.task)
|
|
110
|
-
return [];
|
|
111
|
-
const contexts = [];
|
|
112
|
-
let task = this.task.parent;
|
|
113
|
-
while (task) {
|
|
114
|
-
if (task._saico?.context)
|
|
115
|
-
contexts.unshift(task._saico.context);
|
|
116
|
-
task = task.parent;
|
|
117
|
-
}
|
|
118
|
-
return contexts;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
129
|
_hasPendingToolCalls() {
|
|
122
130
|
const toolCallMsgs = this._msgs.filter(m => m.msg.tool_calls);
|
|
123
131
|
|
|
@@ -291,16 +299,15 @@ class Context {
|
|
|
291
299
|
};
|
|
292
300
|
} else {
|
|
293
301
|
this._trackActiveToolCall(call);
|
|
294
|
-
const _snap = this.
|
|
295
|
-
? JSON.stringify(this.
|
|
302
|
+
const _snap = this._getSnapshot
|
|
303
|
+
? JSON.stringify(this._getSnapshot()) : null;
|
|
296
304
|
|
|
297
305
|
try {
|
|
298
306
|
const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
|
|
299
307
|
const timeout = correspondingDeferred?.originalMessage.opts.timeout;
|
|
300
308
|
|
|
301
309
|
result = await this._executeToolCallWithTimeout(call, timeout);
|
|
302
|
-
if (_snap !== null &&
|
|
303
|
-
_snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
|
|
310
|
+
if (_snap !== null && _snap !== JSON.stringify(this._getSnapshot()))
|
|
304
311
|
this._appendToolDigest(call.function.name, result?.content || '');
|
|
305
312
|
} finally {
|
|
306
313
|
this._completeActiveToolCall(call);
|
|
@@ -390,24 +397,6 @@ class Context {
|
|
|
390
397
|
|
|
391
398
|
getSummaries() { return this._msgs.filter(m => m.opts.summary); }
|
|
392
399
|
|
|
393
|
-
// Get functions aggregated from this context and all ancestor contexts
|
|
394
|
-
getFunctions() {
|
|
395
|
-
const allFunctions = [];
|
|
396
|
-
|
|
397
|
-
// Get functions from ancestor contexts via task hierarchy
|
|
398
|
-
const ancestorContexts = this.getAncestorContexts();
|
|
399
|
-
for (const ctx of ancestorContexts) {
|
|
400
|
-
if (ctx.functions && Array.isArray(ctx.functions))
|
|
401
|
-
allFunctions.push(...ctx.functions);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Add our own functions
|
|
405
|
-
if (this.functions && Array.isArray(this.functions))
|
|
406
|
-
allFunctions.push(...this.functions);
|
|
407
|
-
|
|
408
|
-
return allFunctions.length > 0 ? allFunctions : null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
400
|
async summarizeMessages() {
|
|
412
401
|
const tokens = util.countTokens(this.__msgs);
|
|
413
402
|
if (tokens < this.lower_limit)
|
|
@@ -416,7 +405,7 @@ class Context {
|
|
|
416
405
|
}
|
|
417
406
|
|
|
418
407
|
async close() {
|
|
419
|
-
_log('Closing
|
|
408
|
+
_log('Closing Msgs tag', this.tag);
|
|
420
409
|
|
|
421
410
|
if (this._sequential_mode && this._processing_sequential) {
|
|
422
411
|
_ldbg('Sequential mode: waiting for current message to complete before closing tag', this.tag);
|
|
@@ -429,52 +418,10 @@ class Context {
|
|
|
429
418
|
}
|
|
430
419
|
}
|
|
431
420
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (parentCtx && this._waitingQueue.length > 0) {
|
|
435
|
-
_log('Moving', this._waitingQueue.length, 'waiting messages to parent context');
|
|
436
|
-
parentCtx._waitingQueue.push(...this._waitingQueue);
|
|
437
|
-
this._waitingQueue = [];
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
if (parentCtx && this._sequential_queue.length > 0) {
|
|
441
|
-
_log('Moving', this._sequential_queue.length, 'sequential queue messages to parent context');
|
|
442
|
-
parentCtx._sequential_queue.push(...this._sequential_queue);
|
|
443
|
-
this._sequential_queue = [];
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
await this._summarizeContext(true, parentCtx);
|
|
447
|
-
_log('Finished closing Context tag', this.tag);
|
|
421
|
+
await this._summarizeContext(true);
|
|
422
|
+
_log('Finished closing Msgs tag', this.tag);
|
|
448
423
|
}
|
|
449
424
|
|
|
450
|
-
// Load chat history from store into message queue
|
|
451
|
-
async loadHistory(store) {
|
|
452
|
-
if (!store || !this.tag)
|
|
453
|
-
return;
|
|
454
|
-
const data = await store.load(this.tag);
|
|
455
|
-
if (!data)
|
|
456
|
-
return;
|
|
457
|
-
if (Array.isArray(data.tool_digest))
|
|
458
|
-
this.tool_digest = data.tool_digest;
|
|
459
|
-
if (!data.chat_history)
|
|
460
|
-
return;
|
|
461
|
-
const messages = await util.decompressMessages(data.chat_history);
|
|
462
|
-
if (!Array.isArray(messages) || messages.length === 0)
|
|
463
|
-
return;
|
|
464
|
-
// Find the index after the last system message to insert history
|
|
465
|
-
let insertIdx = 0;
|
|
466
|
-
for (let i = 0; i < this._msgs.length; i++) {
|
|
467
|
-
if (this._msgs[i].msg.role === 'system')
|
|
468
|
-
insertIdx = i + 1;
|
|
469
|
-
}
|
|
470
|
-
const historyMsgs = messages.map(m => ({
|
|
471
|
-
msg: m,
|
|
472
|
-
opts: {},
|
|
473
|
-
msgid: crypto.randomBytes(2).toString('hex'),
|
|
474
|
-
replied: 1
|
|
475
|
-
}));
|
|
476
|
-
this._msgs.splice(insertIdx, 0, ...historyMsgs);
|
|
477
|
-
}
|
|
478
425
|
|
|
479
426
|
// Remove tool-related messages tagged with a specific tag
|
|
480
427
|
cleanToolCallsByTag(tag) {
|
|
@@ -576,38 +523,6 @@ class Context {
|
|
|
576
523
|
return summary;
|
|
577
524
|
}
|
|
578
525
|
|
|
579
|
-
// Get message context - walks up task hierarchy to collect prompts and summaries
|
|
580
|
-
getMsgContext(add_tag) {
|
|
581
|
-
const msgs = [];
|
|
582
|
-
|
|
583
|
-
// Get context from ancestor tasks via task hierarchy
|
|
584
|
-
const ancestorContexts = this.getAncestorContexts();
|
|
585
|
-
for (const ctx of ancestorContexts) {
|
|
586
|
-
if (ctx.prompt)
|
|
587
|
-
msgs.push({role: 'system', content: ctx.prompt});
|
|
588
|
-
// Add summaries from ancestor contexts
|
|
589
|
-
const summaries = ctx._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
|
|
590
|
-
if (add_tag)
|
|
591
|
-
m.msg.tag = ctx.tag;
|
|
592
|
-
return m.msg;
|
|
593
|
-
});
|
|
594
|
-
msgs.push(...summaries);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Add this context's prompt
|
|
598
|
-
if (this.prompt)
|
|
599
|
-
msgs.push({role: 'system', content: this.prompt});
|
|
600
|
-
|
|
601
|
-
// Add this context's summaries
|
|
602
|
-
const mySummaries = this._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
|
|
603
|
-
if (add_tag)
|
|
604
|
-
m.msg.tag = this.tag;
|
|
605
|
-
return m.msg;
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
return msgs.concat(mySummaries);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
526
|
_createMsgObj(role, content, functions, opts) {
|
|
612
527
|
const name = opts?.name;
|
|
613
528
|
const tool_call_id = opts?.tool_call_id;
|
|
@@ -937,10 +852,10 @@ class Context {
|
|
|
937
852
|
? [...o.opts._aggregatedFunctions, ...messageFuncs]
|
|
938
853
|
: null;
|
|
939
854
|
} else {
|
|
940
|
-
const
|
|
855
|
+
const ownFuncs = this.functions || [];
|
|
941
856
|
const messageFuncs = o.functions || [];
|
|
942
|
-
funcs = [...
|
|
943
|
-
? [...
|
|
857
|
+
funcs = [...ownFuncs, ...messageFuncs].length > 0
|
|
858
|
+
? [...ownFuncs, ...messageFuncs]
|
|
944
859
|
: null;
|
|
945
860
|
}
|
|
946
861
|
|
|
@@ -1009,15 +924,15 @@ class Context {
|
|
|
1009
924
|
|
|
1010
925
|
for (const { call, isDuplicate } of toolCallsWithResults) {
|
|
1011
926
|
if (!isDuplicate) {
|
|
1012
|
-
const _snap = this.
|
|
1013
|
-
? JSON.stringify(this.
|
|
927
|
+
const _snap = this._getSnapshot
|
|
928
|
+
? JSON.stringify(this._getSnapshot()) : null;
|
|
1014
929
|
try {
|
|
1015
930
|
const result = await this._executeToolCallWithTimeout(
|
|
1016
931
|
call, o.opts?.timeout);
|
|
1017
932
|
const item = toolCallsWithResults.find(item => item.call.id === call.id);
|
|
1018
933
|
if (item) item.result = result;
|
|
1019
934
|
if (_snap !== null &&
|
|
1020
|
-
_snap !== JSON.stringify(this.
|
|
935
|
+
_snap !== JSON.stringify(this._getSnapshot()))
|
|
1021
936
|
this._appendToolDigest(call.function.name, result?.content || '');
|
|
1022
937
|
} finally {
|
|
1023
938
|
this._completeActiveToolCall(call);
|
|
@@ -1073,39 +988,11 @@ class Context {
|
|
|
1073
988
|
}
|
|
1074
989
|
|
|
1075
990
|
/**
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
991
|
+
* Find a TOOL_<toolName> implementation. Delegates to _findToolImpl callback
|
|
992
|
+
* set by Saico, which searches the hierarchy.
|
|
1078
993
|
*/
|
|
1079
994
|
_findToolImplementation(toolName) {
|
|
1080
|
-
|
|
1081
|
-
const check = (task) =>
|
|
1082
|
-
task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
|
|
1083
|
-
|
|
1084
|
-
// 1. Current task
|
|
1085
|
-
let found = check(this.task);
|
|
1086
|
-
if (found) return { saico: found, methodName };
|
|
1087
|
-
|
|
1088
|
-
// 2. Walk UP parent chain
|
|
1089
|
-
let t = this.task?.parent;
|
|
1090
|
-
while (t) {
|
|
1091
|
-
found = check(t);
|
|
1092
|
-
if (found) return { saico: found, methodName };
|
|
1093
|
-
t = t.parent;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// 3. Walk DOWN from this.task (BFS)
|
|
1097
|
-
if (this.task) {
|
|
1098
|
-
const queue = [...this.task.child];
|
|
1099
|
-
while (queue.length > 0) {
|
|
1100
|
-
const child = queue.shift();
|
|
1101
|
-
if (child._completed) continue;
|
|
1102
|
-
found = check(child);
|
|
1103
|
-
if (found) return { saico: found, methodName };
|
|
1104
|
-
if (child.child?.size > 0) queue.push(...child.child);
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
return null;
|
|
995
|
+
return this._findToolImpl ? this._findToolImpl(toolName) : null;
|
|
1109
996
|
}
|
|
1110
997
|
|
|
1111
998
|
async interpretAndApplyChanges(call) {
|
|
@@ -1154,35 +1041,11 @@ class Context {
|
|
|
1154
1041
|
return { content, functions };
|
|
1155
1042
|
}
|
|
1156
1043
|
|
|
1157
|
-
// Spawn child context (creates a child task with its own context)
|
|
1158
|
-
spawnChild(prompt, tag, config = {}) {
|
|
1159
|
-
if (!this.task) {
|
|
1160
|
-
// If no task, create a standalone context
|
|
1161
|
-
return createContext(prompt, null, { ...config, tag });
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Create a child task with its own context
|
|
1165
|
-
const Itask = require('./itask.js');
|
|
1166
|
-
const childTask = new Itask({
|
|
1167
|
-
name: tag || 'child-context',
|
|
1168
|
-
async: true,
|
|
1169
|
-
}, []);
|
|
1170
|
-
this.task.spawn(childTask);
|
|
1171
|
-
|
|
1172
|
-
const childContext = new Context(prompt, childTask, { ...config, tag });
|
|
1173
|
-
// Store context on Saico if present, otherwise just set on task reference
|
|
1174
|
-
if (childTask._saico) {
|
|
1175
|
-
childTask._saico.context = childContext;
|
|
1176
|
-
childTask._saico.context_id = childContext.tag;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
return childContext;
|
|
1180
|
-
}
|
|
1181
1044
|
}
|
|
1182
1045
|
|
|
1183
|
-
// Factory function to create a
|
|
1184
|
-
function
|
|
1185
|
-
const instance = new
|
|
1046
|
+
// Factory function to create a Msgs instance with Proxy wrapper
|
|
1047
|
+
function createMsgs(prompt, config = {}) {
|
|
1048
|
+
const instance = new Msgs(prompt, config);
|
|
1186
1049
|
|
|
1187
1050
|
return new Proxy(instance, {
|
|
1188
1051
|
get(target, prop, receiver) {
|
|
@@ -1231,4 +1094,4 @@ function createContext(prompt, task, config = {}) {
|
|
|
1231
1094
|
});
|
|
1232
1095
|
}
|
|
1233
1096
|
|
|
1234
|
-
module.exports = {
|
|
1097
|
+
module.exports = { Msgs, createMsgs };
|
package/package.json
CHANGED
package/saico.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const Itask = require('./itask.js');
|
|
5
|
-
const {
|
|
5
|
+
const { Msgs } = require('./msgs.js');
|
|
6
6
|
const { Store } = require('./store.js');
|
|
7
7
|
const util = require('./util.js');
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ class Saico {
|
|
|
47
47
|
*/
|
|
48
48
|
constructor(opt = {}) {
|
|
49
49
|
// Internal properties (underscore-prefixed, not persisted to Redis)
|
|
50
|
-
this.
|
|
50
|
+
this.id = opt.id || crypto.randomBytes(8).toString('hex');
|
|
51
51
|
this._task = null;
|
|
52
52
|
this._store = opt.store || Store.instance || null;
|
|
53
53
|
this._opt = opt;
|
|
@@ -91,7 +91,7 @@ class Saico {
|
|
|
91
91
|
try {
|
|
92
92
|
const redis = require('./redis.js');
|
|
93
93
|
if (redis.rclient && opt.redis !== false) {
|
|
94
|
-
const key = 'saico:' + (opt.key || this.
|
|
94
|
+
const key = 'saico:' + (opt.key || this.id);
|
|
95
95
|
return redis.createObservableForRedis(key, this);
|
|
96
96
|
}
|
|
97
97
|
} catch (e) { /* redis not available */ }
|
|
@@ -152,14 +152,21 @@ class Saico {
|
|
|
152
152
|
sequential_mode: opts.sequential_mode,
|
|
153
153
|
msgs: opts.msgs,
|
|
154
154
|
chat_history: opts.chat_history,
|
|
155
|
+
tool_digest: opts.tool_digest,
|
|
155
156
|
...opts.contextConfig,
|
|
156
157
|
};
|
|
157
158
|
|
|
158
159
|
const augmentedPrompt = effectivePrompt
|
|
159
160
|
? effectivePrompt + Saico.BACKEND_EXPLANATION
|
|
160
161
|
: '';
|
|
161
|
-
const
|
|
162
|
-
this.
|
|
162
|
+
const msgs = new Msgs(augmentedPrompt, contextConfig);
|
|
163
|
+
this.context = msgs;
|
|
164
|
+
this.context_id = makeId(16);
|
|
165
|
+
msgs.tag = this.context_id;
|
|
166
|
+
|
|
167
|
+
// Wire callbacks for hierarchy access
|
|
168
|
+
msgs._findToolImpl = (toolName) => this._findToolImpl(toolName);
|
|
169
|
+
msgs._getSnapshot = () => msgs._snapshotPublicProps(this);
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
return this;
|
|
@@ -167,29 +174,6 @@ class Saico {
|
|
|
167
174
|
|
|
168
175
|
// ---- Context management (owned by Saico, not Itask) ----
|
|
169
176
|
|
|
170
|
-
/**
|
|
171
|
-
* Set context on this Saico instance.
|
|
172
|
-
* Generates context_id, sets context.tag, and calls context.setTask().
|
|
173
|
-
*/
|
|
174
|
-
setContext(context) {
|
|
175
|
-
this.context = context;
|
|
176
|
-
// Generate context_id if not already set
|
|
177
|
-
if (!this.context_id) {
|
|
178
|
-
if (this._store)
|
|
179
|
-
this.context_id = this._store.generateId();
|
|
180
|
-
else if (Store.instance)
|
|
181
|
-
this.context_id = Store.instance.generateId();
|
|
182
|
-
else
|
|
183
|
-
this.context_id = makeId(16);
|
|
184
|
-
}
|
|
185
|
-
if (context) {
|
|
186
|
-
context.tag = this.context_id;
|
|
187
|
-
if (typeof context.setTask === 'function')
|
|
188
|
-
context.setTask(this._task);
|
|
189
|
-
}
|
|
190
|
-
return this;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
177
|
/**
|
|
194
178
|
* Find the nearest context walking UP the Saico/task hierarchy.
|
|
195
179
|
*/
|
|
@@ -223,51 +207,6 @@ class Saico {
|
|
|
223
207
|
return deepest ? deepest.context : null;
|
|
224
208
|
}
|
|
225
209
|
|
|
226
|
-
/**
|
|
227
|
-
* Close this Saico's context and bubble summary to parent.
|
|
228
|
-
*/
|
|
229
|
-
async closeContext() {
|
|
230
|
-
if (!this.context)
|
|
231
|
-
return;
|
|
232
|
-
|
|
233
|
-
// Clean tool call messages tagged with this context_id
|
|
234
|
-
if (this.context_id && typeof this.context.cleanToolCallsByTag === 'function')
|
|
235
|
-
this.context.cleanToolCallsByTag(this.context_id);
|
|
236
|
-
|
|
237
|
-
// Filter out tool calls and [BACKEND] messages, compress remaining as chat_history
|
|
238
|
-
const cleanedMsgs = this.context._msgs.filter(m => {
|
|
239
|
-
if (m.msg.tool_calls) return false;
|
|
240
|
-
if (m.msg.role === 'tool') return false;
|
|
241
|
-
if (typeof m.msg.content === 'string' && m.msg.content.startsWith('[BACKEND]')) return false;
|
|
242
|
-
return true;
|
|
243
|
-
}).map(m => m.msg);
|
|
244
|
-
|
|
245
|
-
// Trim to last QUEUE_LIMIT before persisting
|
|
246
|
-
const queueLimit = this.context.QUEUE_LIMIT || 30;
|
|
247
|
-
const trimmedMsgs = cleanedMsgs.length > queueLimit
|
|
248
|
-
? cleanedMsgs.slice(-queueLimit)
|
|
249
|
-
: cleanedMsgs;
|
|
250
|
-
|
|
251
|
-
if (trimmedMsgs.length > 0) {
|
|
252
|
-
const chat_history = await util.compressMessages(trimmedMsgs);
|
|
253
|
-
this.context.chat_history = chat_history;
|
|
254
|
-
|
|
255
|
-
// Persist to store
|
|
256
|
-
const store = this._store || Store.instance;
|
|
257
|
-
if (store && this.context_id) {
|
|
258
|
-
await store.save(this.context_id, {
|
|
259
|
-
chat_history,
|
|
260
|
-
tool_digest: this.context.tool_digest || [],
|
|
261
|
-
prompt: this.context.prompt,
|
|
262
|
-
tag: this.context.tag,
|
|
263
|
-
tm_closed: Date.now()
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
await this.context.close();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
210
|
/**
|
|
272
211
|
* Deactivate — bubble cleaned messages to parent, close context, cancel task.
|
|
273
212
|
* Pushes cleaned messages (no tool calls, no BACKEND) into the parent's Q,
|
|
@@ -309,15 +248,16 @@ class Saico {
|
|
|
309
248
|
spawn(child) {
|
|
310
249
|
if (!this._task)
|
|
311
250
|
throw new Error('Not activated. Call activate() first.');
|
|
312
|
-
if (!(child instanceof Saico)
|
|
313
|
-
throw new Error('Child must be
|
|
251
|
+
if (!(child instanceof Saico))
|
|
252
|
+
throw new Error('Child must be a Saico instance.');
|
|
253
|
+
if (!child._task) child.activate();
|
|
314
254
|
this._task.spawn(child._task);
|
|
315
255
|
return child;
|
|
316
256
|
}
|
|
317
257
|
|
|
318
258
|
/**
|
|
319
259
|
* Spawn a child Saico and start its task running.
|
|
320
|
-
* @param {Saico} child -
|
|
260
|
+
* @param {Saico} child - A Saico instance (auto-activated if needed)
|
|
321
261
|
* @returns {Saico} the child (for chaining)
|
|
322
262
|
*/
|
|
323
263
|
spawnAndRun(child) {
|
|
@@ -487,6 +427,41 @@ class Saico {
|
|
|
487
427
|
return parts.length > 0 ? parts : null;
|
|
488
428
|
}
|
|
489
429
|
|
|
430
|
+
// ---- Tool implementation search ----
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Search the Saico hierarchy for a TOOL_<toolName> method.
|
|
434
|
+
* Order: current task → walk UP parents → walk DOWN children (BFS).
|
|
435
|
+
*/
|
|
436
|
+
_findToolImpl(toolName) {
|
|
437
|
+
const methodName = 'TOOL_' + toolName;
|
|
438
|
+
const check = (task) =>
|
|
439
|
+
task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
|
|
440
|
+
|
|
441
|
+
let found = check(this._task);
|
|
442
|
+
if (found) return { saico: found, methodName };
|
|
443
|
+
|
|
444
|
+
let t = this._task?.parent;
|
|
445
|
+
while (t) {
|
|
446
|
+
found = check(t);
|
|
447
|
+
if (found) return { saico: found, methodName };
|
|
448
|
+
t = t.parent;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (this._task) {
|
|
452
|
+
const queue = [...this._task.child];
|
|
453
|
+
while (queue.length > 0) {
|
|
454
|
+
const child = queue.shift();
|
|
455
|
+
if (child._completed) continue;
|
|
456
|
+
found = check(child);
|
|
457
|
+
if (found) return { saico: found, methodName };
|
|
458
|
+
if (child.child?.size > 0) queue.push(...child.child);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
|
|
490
465
|
// ---- User Data (absorbed from Sid) ----
|
|
491
466
|
|
|
492
467
|
setUserData(key, value) {
|
|
@@ -507,7 +482,7 @@ class Saico {
|
|
|
507
482
|
|
|
508
483
|
getSessionInfo() {
|
|
509
484
|
return {
|
|
510
|
-
id: this.
|
|
485
|
+
id: this.id,
|
|
511
486
|
name: this.name,
|
|
512
487
|
running: this._task?.running || false,
|
|
513
488
|
completed: this._task?._completed || false,
|
|
@@ -518,10 +493,38 @@ class Saico {
|
|
|
518
493
|
};
|
|
519
494
|
}
|
|
520
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Close the session — compress msgs, save full state to Store, cancel task.
|
|
498
|
+
* The saved object has the same shape as serialize() but with compressed
|
|
499
|
+
* context messages (chat_history) instead of raw _msgs.
|
|
500
|
+
*/
|
|
521
501
|
async closeSession() {
|
|
522
502
|
if (!this._task) return;
|
|
523
|
-
|
|
524
|
-
|
|
503
|
+
|
|
504
|
+
// Save full state to Store with compressed msgs
|
|
505
|
+
const store = this._store || Store.instance;
|
|
506
|
+
if (store && this.context) {
|
|
507
|
+
const { chat_history, tool_digest } = await this.context.prepareForStorage();
|
|
508
|
+
const data = {
|
|
509
|
+
id: this.id,
|
|
510
|
+
name: this.name,
|
|
511
|
+
prompt: this.prompt,
|
|
512
|
+
userData: this.userData,
|
|
513
|
+
sessionConfig: this.sessionConfig,
|
|
514
|
+
tm_create: this.tm_create,
|
|
515
|
+
isolate: this._isolate,
|
|
516
|
+
taskId: this._task.id,
|
|
517
|
+
context_id: this.context_id,
|
|
518
|
+
context: {
|
|
519
|
+
tag: this.context.tag,
|
|
520
|
+
chat_history,
|
|
521
|
+
tool_digest,
|
|
522
|
+
functions: this.context.functions,
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
await store.save(this.id, data);
|
|
526
|
+
}
|
|
527
|
+
|
|
525
528
|
this._task._ecancel();
|
|
526
529
|
}
|
|
527
530
|
|
|
@@ -625,9 +628,14 @@ class Saico {
|
|
|
625
628
|
|
|
626
629
|
// ---- Serialization ----
|
|
627
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Serialize the Saico instance to a JSON string.
|
|
633
|
+
* Context messages are included as raw _msgs (for Redis / in-memory use).
|
|
634
|
+
* For durable storage with compressed msgs, use closeSession().
|
|
635
|
+
*/
|
|
628
636
|
serialize() {
|
|
629
637
|
const data = {
|
|
630
|
-
id: this.
|
|
638
|
+
id: this.id,
|
|
631
639
|
name: this.name,
|
|
632
640
|
prompt: this.prompt,
|
|
633
641
|
userData: this.userData,
|
|
@@ -635,24 +643,21 @@ class Saico {
|
|
|
635
643
|
tm_create: this.tm_create,
|
|
636
644
|
isolate: this._isolate,
|
|
637
645
|
};
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
chat_history: this.context.chat_history,
|
|
647
|
-
tool_digest: this.context.tool_digest,
|
|
648
|
-
} : null,
|
|
649
|
-
};
|
|
650
|
-
}
|
|
646
|
+
data.taskId = this._task?.id || null;
|
|
647
|
+
data.context_id = this.context_id || null;
|
|
648
|
+
data.context = this.context ? {
|
|
649
|
+
tag: this.context.tag,
|
|
650
|
+
msgs: this.context._msgs,
|
|
651
|
+
functions: this.context.functions,
|
|
652
|
+
tool_digest: this.context.tool_digest,
|
|
653
|
+
} : null;
|
|
651
654
|
return JSON.stringify(data);
|
|
652
655
|
}
|
|
653
656
|
|
|
654
657
|
/**
|
|
655
658
|
* Restore a Saico instance from serialized data.
|
|
659
|
+
* Supports both raw msgs (from serialize/Redis) and compressed
|
|
660
|
+
* chat_history (from closeSession/Store).
|
|
656
661
|
* @param {string|Object} data - Serialized data (JSON string or object)
|
|
657
662
|
* @param {Object} opt - Options (functions, store, states, etc.)
|
|
658
663
|
* @returns {Saico}
|
|
@@ -667,38 +672,54 @@ class Saico {
|
|
|
667
672
|
userData: parsed.userData,
|
|
668
673
|
sessionConfig: parsed.sessionConfig,
|
|
669
674
|
isolate: parsed.isolate,
|
|
670
|
-
functions: opt.functions || parsed.
|
|
675
|
+
functions: opt.functions || parsed.context?.functions,
|
|
671
676
|
store: opt.store,
|
|
672
677
|
redis: false, // No Redis proxy during deserialization
|
|
673
678
|
});
|
|
674
679
|
|
|
675
680
|
instance.tm_create = parsed.tm_create || instance.tm_create;
|
|
676
681
|
|
|
677
|
-
// Activate with restored
|
|
678
|
-
if (parsed.
|
|
682
|
+
// Activate with restored state if taskId exists
|
|
683
|
+
if (parsed.taskId) {
|
|
684
|
+
const ctx = parsed.context;
|
|
679
685
|
instance.activate({
|
|
680
|
-
createQ: !!
|
|
681
|
-
taskId: parsed.
|
|
682
|
-
tag:
|
|
683
|
-
chat_history:
|
|
684
|
-
functions: opt.functions ||
|
|
686
|
+
createQ: !!ctx,
|
|
687
|
+
taskId: parsed.taskId,
|
|
688
|
+
tag: ctx?.tag,
|
|
689
|
+
chat_history: ctx?.chat_history,
|
|
690
|
+
functions: opt.functions || ctx?.functions,
|
|
691
|
+
tool_digest: ctx?.tool_digest,
|
|
685
692
|
states: opt.states || [],
|
|
686
693
|
...opt,
|
|
687
694
|
});
|
|
688
695
|
|
|
689
|
-
// Restore
|
|
690
|
-
if (
|
|
691
|
-
instance.context._msgs =
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// Restore tool_digest
|
|
695
|
-
if (Array.isArray(parsed.task.context?.tool_digest) && instance.context) {
|
|
696
|
-
instance.context.tool_digest = parsed.task.context.tool_digest;
|
|
696
|
+
// Restore raw msgs (from serialize/Redis) — takes priority over chat_history
|
|
697
|
+
if (ctx?.msgs && instance.context) {
|
|
698
|
+
instance.context._msgs = ctx.msgs;
|
|
697
699
|
}
|
|
698
700
|
}
|
|
699
701
|
|
|
700
702
|
return instance;
|
|
701
703
|
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Load a Saico instance from Store by id.
|
|
707
|
+
* @param {string} id - The Saico instance id
|
|
708
|
+
* @param {Object} opt - Options (store, functions, states, etc.)
|
|
709
|
+
* @returns {Promise<Saico|null>}
|
|
710
|
+
*/
|
|
711
|
+
static async rehydrate(id, opt = {}) {
|
|
712
|
+
const store = opt.store || Store.instance;
|
|
713
|
+
if (!store)
|
|
714
|
+
throw new Error('No store available for rehydrate');
|
|
715
|
+
const data = await store.load(id);
|
|
716
|
+
if (!data) return null;
|
|
717
|
+
const instance = Saico.deserialize(data, opt);
|
|
718
|
+
// Decompress chat_history into _msgs if present
|
|
719
|
+
if (instance.context)
|
|
720
|
+
await instance.context.initHistory();
|
|
721
|
+
return instance;
|
|
722
|
+
}
|
|
702
723
|
}
|
|
703
724
|
|
|
704
725
|
// [BACKEND] explanation text appended to context prompts
|