saico 2.2.3 → 2.4.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/msgs.js ADDED
@@ -0,0 +1,1167 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const openai = require('./openai.js');
5
+ const util = require('./util.js');
6
+
7
+ const { _log, _lerr, _ldbg } = util;
8
+ const debug = 0;
9
+
10
+ /**
11
+ * Context - Conversation context that can be attached to any Itask.
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
18
+ */
19
+ class Context {
20
+ constructor(prompt, task, config = {}) {
21
+ this.prompt = prompt;
22
+ this.task = task; // Reference to owning Itask (replaces parent)
23
+ this.tag = config.tag || crypto.randomBytes(4).toString('hex');
24
+ this.token_limit = config.token_limit || 1000000000;
25
+ this.lower_limit = this.token_limit * 0.85;
26
+ this.upper_limit = this.token_limit * 0.98;
27
+ this.tool_handler = config.tool_handler || task?.tool_handler;
28
+ this.functions = config.functions || task?.functions || null;
29
+
30
+ // Recursive depth and repetition control
31
+ this.max_depth = config.max_depth || 5;
32
+ this.max_tool_repetition = config.max_tool_repetition || 20;
33
+ this._current_depth = 0;
34
+ this._deferred_tool_calls = [];
35
+ this._tool_call_sequence = [];
36
+
37
+ // Chat history persistence
38
+ this.chat_history = config.chat_history || null;
39
+
40
+ this._msgs = [];
41
+ this._waitingQueue = [];
42
+ this._active_tool_calls = new Map();
43
+
44
+ // Sequential mode support
45
+ this._sequential_queue = [];
46
+ this._processing_sequential = false;
47
+ this._sequential_mode = config.sequential_mode || false;
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
+
57
+ // Initialize messages if provided
58
+ (config.msgs || []).forEach(m => this.push(m));
59
+
60
+ _log('created Context for tag', this.tag);
61
+ }
62
+
63
+ // Set the task reference (used when context is created separately)
64
+ setTask(task) {
65
+ this.task = task;
66
+ if (!this.tool_handler)
67
+ this.tool_handler = task?.tool_handler;
68
+ if (!this.functions)
69
+ this.functions = task?.functions;
70
+ }
71
+
72
+ // Snapshot all public (non-underscore) task properties for dirty detection.
73
+ // Mirrors the observable proxy convention: _ prefix = internal, ignored.
74
+ // Does NOT call serialize() — that is for persistence, not dirty detection.
75
+ _snapshotPublicProps(obj, seen = new Set()) {
76
+ if (typeof obj !== 'object' || obj === null) return obj;
77
+ if (seen.has(obj)) return undefined; // circular ref — skip
78
+ seen.add(obj);
79
+ const out = Array.isArray(obj) ? [] : {};
80
+ for (const key of Object.keys(obj)) {
81
+ if (!key.startsWith('_') && typeof obj[key] !== 'function')
82
+ out[key] = this._snapshotPublicProps(obj[key], seen);
83
+ }
84
+ seen.delete(obj);
85
+ return out;
86
+ }
87
+
88
+ // Append a tool result to the persistent tool digest
89
+ _appendToolDigest(toolName, resultContent) {
90
+ const truncated = typeof resultContent === 'string'
91
+ ? resultContent.slice(0, 500)
92
+ : String(resultContent ?? '').slice(0, 500);
93
+ this.tool_digest.push({ tool: toolName, result: truncated, tm: Date.now() });
94
+ if (this.tool_digest.length > this.TOOL_DIGEST_LIMIT)
95
+ this.tool_digest = this.tool_digest.slice(-this.TOOL_DIGEST_LIMIT);
96
+ }
97
+
98
+ // Get the parent context by traversing task hierarchy
99
+ getParentContext() {
100
+ if (!this.task || !this.task.parent)
101
+ return null;
102
+ return this.task.parent.findContext ? this.task.parent.findContext() : null;
103
+ }
104
+
105
+ // Get all ancestor contexts via task hierarchy
106
+ getAncestorContexts() {
107
+ if (!this.task)
108
+ return [];
109
+ return this.task.getAncestorContexts().filter(ctx => ctx !== this);
110
+ }
111
+
112
+ _hasPendingToolCalls() {
113
+ const toolCallMsgs = this._msgs.filter(m => m.msg.tool_calls);
114
+
115
+ for (const toolCallMsg of toolCallMsgs) {
116
+ const toolCalls = toolCallMsg.msg.tool_calls;
117
+ const toolCallIds = toolCalls.map(tc => tc.id);
118
+
119
+ const toolReplies = this._msgs.filter(m =>
120
+ m.msg.role === 'tool' &&
121
+ toolCallIds.includes(m.msg.tool_call_id)
122
+ );
123
+
124
+ const repliedCallIds = new Set(toolReplies.map(r => r.msg.tool_call_id));
125
+ const deferredCallIds = new Set(this._deferred_tool_calls.map(d => d.call.id));
126
+ const unRepliedCalls = toolCalls.filter(tc =>
127
+ !repliedCallIds.has(tc.id) && !deferredCallIds.has(tc.id)
128
+ );
129
+
130
+ if (unRepliedCalls.length > 0)
131
+ return true;
132
+ }
133
+
134
+ return false;
135
+ }
136
+
137
+ _processWaitingQueue() {
138
+ _log('Processing waiting queue,', this._waitingQueue.length, 'messages waiting');
139
+
140
+ this._waitingQueue.forEach(waitingMessage => {
141
+ _log('Adding queued message to queue:', waitingMessage.role,
142
+ waitingMessage.content?.slice(0, 50));
143
+ this._createMsgObj(
144
+ waitingMessage.role,
145
+ waitingMessage.content,
146
+ waitingMessage.functions,
147
+ waitingMessage.opts
148
+ );
149
+ });
150
+
151
+ this._waitingQueue = [];
152
+ }
153
+
154
+ async _processSequentialQueue() {
155
+ if (this._processing_sequential || this._sequential_queue.length === 0)
156
+ return;
157
+
158
+ _ldbg('[' + this.tag + '] Starting sequential queue processing');
159
+ this._processing_sequential = true;
160
+
161
+ try {
162
+ while (this._sequential_queue.length > 0) {
163
+ const queuedMsg = this._sequential_queue.shift();
164
+ _ldbg('Processing sequential message:', queuedMsg.role,
165
+ queuedMsg.content?.slice(0, 50));
166
+
167
+ try {
168
+ const result = await this._sendMessageInternal(
169
+ queuedMsg.role,
170
+ queuedMsg.content,
171
+ queuedMsg.functions,
172
+ queuedMsg.opts
173
+ );
174
+
175
+ if (queuedMsg.resolve)
176
+ queuedMsg.resolve(result);
177
+ } catch (err) {
178
+ if (queuedMsg.reject)
179
+ queuedMsg.reject(err);
180
+ else
181
+ _lerr('Error processing queued message:', err);
182
+ }
183
+ }
184
+ _ldbg('[' + this.tag + '] Sequential queue processing completed');
185
+ } catch (err) {
186
+ _lerr('Error processing sequential queue:', err);
187
+ } finally {
188
+ _ldbg('[' + this.tag + '] Setting _processing_sequential = false');
189
+ this._processing_sequential = false;
190
+ }
191
+ }
192
+
193
+ _trackToolCall(toolName) {
194
+ this._tool_call_sequence.push(toolName);
195
+
196
+ if (this._tool_call_sequence.length > this.max_tool_repetition * 2) {
197
+ this._tool_call_sequence = this._tool_call_sequence.slice(-this.max_tool_repetition);
198
+ }
199
+ }
200
+
201
+ _shouldDropToolCall(toolName) {
202
+ if (this._tool_call_sequence.length < this.max_tool_repetition)
203
+ return false;
204
+
205
+ let consecutiveCount = 0;
206
+ for (let i = this._tool_call_sequence.length - 1; i >= 0; i--) {
207
+ if (this._tool_call_sequence[i] === toolName) {
208
+ consecutiveCount++;
209
+ } else {
210
+ break;
211
+ }
212
+ }
213
+
214
+ return consecutiveCount >= this.max_tool_repetition;
215
+ }
216
+
217
+ _resetToolSequenceIfDifferent(newToolNames) {
218
+ if (!newToolNames || newToolNames.length === 0) {
219
+ this._tool_call_sequence = [];
220
+ return;
221
+ }
222
+
223
+ const lastTool = this._tool_call_sequence[this._tool_call_sequence.length - 1];
224
+ if (!lastTool || !newToolNames.includes(lastTool)) {
225
+ this._tool_call_sequence = [];
226
+ }
227
+ }
228
+
229
+ _filterExcessiveToolCalls(toolCalls) {
230
+ if (!toolCalls || toolCalls.length === 0) return toolCalls;
231
+
232
+ return toolCalls.filter(call => {
233
+ const toolName = call.function.name;
234
+ if (this._shouldDropToolCall(toolName)) {
235
+ _log('Dropping excessive tool call:', toolName,
236
+ '(hit max_tool_repetition=' + this.max_tool_repetition + ')');
237
+ return false;
238
+ }
239
+ return true;
240
+ });
241
+ }
242
+
243
+ async _processDeferredToolCalls() {
244
+ if (this._deferred_tool_calls.length === 0) return;
245
+
246
+ _log('Processing deferred tool calls:', this._deferred_tool_calls.length);
247
+
248
+ const deferredCalls = [...this._deferred_tool_calls];
249
+ this._deferred_tool_calls = [];
250
+
251
+ const callsByMessage = new Map();
252
+ for (const deferred of deferredCalls) {
253
+ const key = deferred.originalMessage.msgid;
254
+ if (!callsByMessage.has(key)) {
255
+ callsByMessage.set(key, []);
256
+ }
257
+ callsByMessage.get(key).push(deferred);
258
+ }
259
+
260
+ for (const [msgid, deferredGroup] of callsByMessage) {
261
+ _log('Processing deferred group for message', msgid + ':', deferredGroup.length, 'calls');
262
+
263
+ const toolCalls = deferredGroup.map(d => d.call);
264
+ const toolNames = toolCalls.map(call => call.function.name);
265
+ this._resetToolSequenceIfDifferent(toolNames);
266
+
267
+ const filteredToolCalls = this._filterExcessiveToolCalls(toolCalls);
268
+
269
+ let reply2 = {};
270
+ for (const [i, call] of filteredToolCalls.entries()) {
271
+ const toolName = call.function.name;
272
+ this._trackToolCall(toolName);
273
+
274
+ let result;
275
+
276
+ if (this._isDuplicateToolCall(call)) {
277
+ _log('Duplicate deferred tool call detected:', call.function.name);
278
+ result = {
279
+ content: `Duplicate call detected. An identical "${call.function.name}" ` +
280
+ `tool call with the same arguments is already running.`,
281
+ functions: null
282
+ };
283
+ } else {
284
+ this._trackActiveToolCall(call);
285
+ const _snap = this.task
286
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
287
+
288
+ try {
289
+ const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
290
+ const handler = correspondingDeferred?.originalMessage.opts.handler || this.tool_handler;
291
+ const timeout = correspondingDeferred?.originalMessage.opts.timeout;
292
+
293
+ result = await this._executeToolCallWithTimeout(call, handler, timeout);
294
+ if (_snap !== null &&
295
+ _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
296
+ this._appendToolDigest(call.function.name, result?.content || '');
297
+ } finally {
298
+ this._completeActiveToolCall(call);
299
+ }
300
+ }
301
+
302
+ const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
303
+ const opts = {
304
+ name: call.function.name,
305
+ tool_call_id: call.id,
306
+ _recursive_depth: 1,
307
+ model: correspondingDeferred?.originalMessage.opts.model
308
+ };
309
+ const content = result ? (result.content || result) : '';
310
+ const functions = (i === filteredToolCalls.length - 1 && result && result.functions)
311
+ ? result.functions : null;
312
+
313
+ if (i === filteredToolCalls.length - 1) {
314
+ reply2 = await this.sendMessage('tool', content, functions, opts);
315
+ } else {
316
+ const toolResponse = this._createMsgObj('tool', content, null, opts);
317
+ toolResponse.replied = 1;
318
+ this._insertToolResponseAtCorrectPosition(toolResponse, call.id);
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ get messages() {
325
+ return this.__msgs;
326
+ }
327
+
328
+ set messages(value) {
329
+ if (Array.isArray(value)) {
330
+ this._msgs = value.map(m => m.msg ? m : {msg: m, opts: {}, replied: 1});
331
+ } else {
332
+ throw new Error("messages must be assigned an array");
333
+ }
334
+ }
335
+
336
+ push(msg) {
337
+ const m = {msg: msg.msg || msg, opts: msg.opts || {}, msgid: msg.msgid || 0, replied: msg.replied || 2};
338
+ return this._msgs.push(m);
339
+ }
340
+
341
+ pushSummary(summary) {
342
+ const idx = this.push({role: 'user', content: '[SUMMARY]: ' + summary});
343
+ this._msgs[idx - 1].opts.summary = true;
344
+ }
345
+
346
+ toJSON() {
347
+ return this.__msgs;
348
+ }
349
+
350
+ filter(callback) {
351
+ return this.__msgs.filter(callback);
352
+ }
353
+
354
+ concat(arr) {
355
+ return this.__msgs.concat(arr);
356
+ }
357
+
358
+ slice(start, end) {
359
+ return this.__msgs.slice(start, end);
360
+ }
361
+
362
+ reverse() {
363
+ this._msgs.reverse();
364
+ return this;
365
+ }
366
+
367
+ [Symbol.iterator]() {
368
+ return (function* () {
369
+ for (const item of this._msgs) {
370
+ yield item.msg;
371
+ }
372
+ }).call(this);
373
+ }
374
+
375
+ get __msgs() { return this._msgs.map(m => m.msg); }
376
+
377
+ get length() {
378
+ return this._msgs.length;
379
+ }
380
+
381
+ serialize() { return JSON.stringify(this._msgs); }
382
+
383
+ getSummaries() { return this._msgs.filter(m => m.opts.summary); }
384
+
385
+ // Get functions aggregated from this context and all ancestor contexts
386
+ getFunctions() {
387
+ const allFunctions = [];
388
+
389
+ // Get functions from ancestor contexts via task hierarchy
390
+ const ancestorContexts = this.getAncestorContexts();
391
+ for (const ctx of ancestorContexts) {
392
+ if (ctx.functions && Array.isArray(ctx.functions))
393
+ allFunctions.push(...ctx.functions);
394
+ }
395
+
396
+ // Add our own functions
397
+ if (this.functions && Array.isArray(this.functions))
398
+ allFunctions.push(...this.functions);
399
+
400
+ return allFunctions.length > 0 ? allFunctions : null;
401
+ }
402
+
403
+ async summarizeMessages() {
404
+ const tokens = util.countTokens(this.__msgs);
405
+ if (tokens < this.lower_limit)
406
+ return;
407
+ await this._summarizeContext();
408
+ }
409
+
410
+ async close() {
411
+ _log('Closing Context tag', this.tag);
412
+
413
+ if (this._sequential_mode && this._processing_sequential) {
414
+ _ldbg('Sequential mode: waiting for current message to complete before closing tag', this.tag);
415
+ let waitCount = 0;
416
+ while (this._processing_sequential) {
417
+ await new Promise(resolve => setTimeout(resolve, 100));
418
+ waitCount++;
419
+ if (waitCount % 10 === 0)
420
+ _ldbg('Sequential mode: still waiting for tag', this.tag, 'after', waitCount, 'iterations');
421
+ }
422
+ }
423
+
424
+ // Move waiting messages to parent context via task hierarchy
425
+ const parentCtx = this.getParentContext();
426
+ if (parentCtx && this._waitingQueue.length > 0) {
427
+ _log('Moving', this._waitingQueue.length, 'waiting messages to parent context');
428
+ parentCtx._waitingQueue.push(...this._waitingQueue);
429
+ this._waitingQueue = [];
430
+ }
431
+
432
+ if (parentCtx && this._sequential_queue.length > 0) {
433
+ _log('Moving', this._sequential_queue.length, 'sequential queue messages to parent context');
434
+ parentCtx._sequential_queue.push(...this._sequential_queue);
435
+ this._sequential_queue = [];
436
+ }
437
+
438
+ await this._summarizeContext(true, parentCtx);
439
+ _log('Finished closing Context tag', this.tag);
440
+ }
441
+
442
+ // Load chat history from store into message queue
443
+ async loadHistory(store) {
444
+ if (!store || !this.tag)
445
+ return;
446
+ const data = await store.load(this.tag);
447
+ if (!data)
448
+ return;
449
+ if (Array.isArray(data.tool_digest))
450
+ this.tool_digest = data.tool_digest;
451
+ if (!data.chat_history)
452
+ return;
453
+ const messages = await util.decompressMessages(data.chat_history);
454
+ if (!Array.isArray(messages) || messages.length === 0)
455
+ return;
456
+ // Find the index after the last system message to insert history
457
+ let insertIdx = 0;
458
+ for (let i = 0; i < this._msgs.length; i++) {
459
+ if (this._msgs[i].msg.role === 'system')
460
+ insertIdx = i + 1;
461
+ }
462
+ const historyMsgs = messages.map(m => ({
463
+ msg: m,
464
+ opts: {},
465
+ msgid: crypto.randomBytes(2).toString('hex'),
466
+ replied: 1
467
+ }));
468
+ this._msgs.splice(insertIdx, 0, ...historyMsgs);
469
+ }
470
+
471
+ // Remove tool-related messages tagged with a specific tag
472
+ cleanToolCallsByTag(tag) {
473
+ this._msgs = this._msgs.filter(m => {
474
+ if (m.opts.tag !== tag)
475
+ return true;
476
+ if (m.msg.tool_calls)
477
+ return false;
478
+ if (m.msg.role === 'tool')
479
+ return false;
480
+ return true;
481
+ });
482
+ }
483
+
484
+ async _summarizeContext(close, targetCtx) {
485
+ const keep = this._msgs.filter(m => !close && m.summary);
486
+ const summarize = this._msgs.filter(m => (!close || !m.summary) && m.replied);
487
+ const not_replied = this._msgs.filter(m => !m.replied);
488
+ _ldbg('Start summarize messages. # messages', summarize.length, '(total msgs:', this._msgs.length + ')');
489
+
490
+ if (!summarize.length) {
491
+ _ldbg('[' + this.tag + '] No messages to summarize');
492
+ return;
493
+ }
494
+
495
+ const msgs = (close ? [{role: 'system', content: this.prompt}] : []).concat(summarize.map(m => m.msg));
496
+ const summary = await this._summarizeMessages(msgs);
497
+ this._msgs = keep;
498
+
499
+ if (summary) {
500
+ if (close && targetCtx)
501
+ targetCtx.pushSummary(summary);
502
+ else
503
+ this.pushSummary(summary);
504
+ }
505
+
506
+ this._msgs.push(...not_replied);
507
+ _log('Summarized', this.tag, '(close', close + ') conversation to', util.countTokens(this.__msgs),
508
+ 'tokens # messages', this._msgs.length);
509
+ }
510
+
511
+ async _summarizeMessages(msgs) {
512
+ let chunks = [msgs];
513
+ const tokens = util.countTokens(chunks[0]);
514
+
515
+ if (tokens > this.upper_limit) {
516
+ chunks = [];
517
+ let chunk_msgs = [];
518
+ let chunk = '';
519
+
520
+ msgs.forEach(m => {
521
+ if (typeof m !== 'object' || Array.isArray(m))
522
+ return _lerr('discarding msg with corrupt structure', m);
523
+ const keys = Object.keys(m);
524
+ for (const k of keys) {
525
+ if (!['role', 'content', 'refusal', 'name', 'tool', 'tool_calls'].includes(k))
526
+ return _lerr('discarding msg with corrupt key', k);
527
+ }
528
+ if (util.countTokens(m) > this.upper_limit)
529
+ return _lerr('discard abnormal size message', tokens, 'tokens\n' + m.content?.slice(0, 1500));
530
+ if (m.function)
531
+ m.content = '<function data>';
532
+ const str = `${m.role.toUpperCase()}: ${m.content || JSON.stringify(m.tool_calls)}`;
533
+ if (util.countTokens(chunk + str + '\n') < this.token_limit / 2) {
534
+ chunk += str + '\n';
535
+ chunk_msgs.push(m);
536
+ } else {
537
+ chunks.push(chunk_msgs);
538
+ chunk = '';
539
+ chunk_msgs = [];
540
+ }
541
+ });
542
+ if (chunk_msgs.length)
543
+ chunks.push(chunk_msgs);
544
+ }
545
+
546
+ if (!chunks.length)
547
+ return _log('No msgs for summary found');
548
+
549
+ _log('Summarizing messages. tokens', tokens, 'messages', msgs.length, 'using', chunks.length, 'chunks');
550
+
551
+ let reply = await openai.send([{role: 'system', content:
552
+ 'Please summarize the following conversation. The summary should be one or two paragraphs as follows:' +
553
+ '- First paragraph: the purpose of the conversation and the outcome' +
554
+ '- Second paragraph (optional): next steps or pending requests that should be considered' +
555
+ '- Do not include system errors in the summary.\n' +
556
+ '- Formulate the summary from the AI agent\'s perspective\n' +
557
+ '\nConversation:\n' +
558
+ (chunks.length > 1 ? 'The conversation will be uploaded in ' + chunks.length +
559
+ ' chunks. Wait for the last one then summarize all.\nChunk 1:\n'
560
+ : 'The conversation to summarize:\n') + JSON.stringify(chunks[0])}]);
561
+
562
+ let summary = reply.content;
563
+ for (let i = 1; i < chunks.length; i++) {
564
+ reply = await openai.send([{role: 'system', content:
565
+ 'Chunk ' + (i === chunks.length ? 'last' : i) + ':\n' + JSON.stringify(chunks[i])}]);
566
+ summary = 'Summary of ' + this.tag + ' conversation:\n' + reply.content;
567
+ }
568
+ return summary;
569
+ }
570
+
571
+ // Get message context - walks up task hierarchy to collect prompts and summaries
572
+ getMsgContext(add_tag) {
573
+ const msgs = [];
574
+
575
+ // Get context from ancestor tasks via task hierarchy
576
+ const ancestorContexts = this.getAncestorContexts();
577
+ for (const ctx of ancestorContexts) {
578
+ if (ctx.prompt)
579
+ msgs.push({role: 'system', content: ctx.prompt});
580
+ // Add summaries from ancestor contexts
581
+ const summaries = ctx._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
582
+ if (add_tag)
583
+ m.msg.tag = ctx.tag;
584
+ return m.msg;
585
+ });
586
+ msgs.push(...summaries);
587
+ }
588
+
589
+ // Add this context's prompt
590
+ if (this.prompt)
591
+ msgs.push({role: 'system', content: this.prompt});
592
+
593
+ // Add this context's summaries
594
+ const mySummaries = this._msgs.filter(m => m.opts.summary || m.msg.role === 'system').map(m => {
595
+ if (add_tag)
596
+ m.msg.tag = this.tag;
597
+ return m.msg;
598
+ });
599
+
600
+ return msgs.concat(mySummaries);
601
+ }
602
+
603
+ _createMsgObj(role, content, functions, opts) {
604
+ const name = opts?.name;
605
+ const tool_call_id = opts?.tool_call_id;
606
+ const msg = { role, content, ...(name && { name }), ...(tool_call_id && { tool_call_id }) };
607
+ const msgid = crypto.randomBytes(2).toString('hex');
608
+ const o = {msg, opts: opts || {}, functions, msgid, replied: 0};
609
+ this._msgs.forEach(m => m.opts.noreply ||= !m.replied);
610
+ this._msgs.push(o);
611
+ return o;
612
+ }
613
+
614
+ _insertToolResponseAtCorrectPosition(toolResponseObj, tool_call_id) {
615
+ let insertIndex = -1;
616
+ let originalInsertIndex = -1;
617
+
618
+ for (let i = this._msgs.length - 1; i >= 0; i--) {
619
+ const msg = this._msgs[i];
620
+ if (msg.msg.tool_calls) {
621
+ const hasMatchingCall = msg.msg.tool_calls.some(call => call.id === tool_call_id);
622
+ if (hasMatchingCall) {
623
+ if (insertIndex === -1)
624
+ insertIndex = i + 1;
625
+
626
+ if (msg.msg.content !== 'Processing deferred tool calls') {
627
+ originalInsertIndex = i + 1;
628
+ break;
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ const finalInsertIndex = originalInsertIndex !== -1 ? originalInsertIndex : insertIndex;
635
+
636
+ if (finalInsertIndex !== -1 && finalInsertIndex < this._msgs.length) {
637
+ const lastIndex = this._msgs.length - 1;
638
+ if (this._msgs[lastIndex] === toolResponseObj) {
639
+ this._msgs.pop();
640
+ this._msgs.splice(finalInsertIndex, 0, toolResponseObj);
641
+ }
642
+ }
643
+ }
644
+
645
+ _getToolCallKey(call) {
646
+ return `${call.function.name}:${call.function.arguments}`;
647
+ }
648
+
649
+ _isDuplicateToolCall(call) {
650
+ const key = this._getToolCallKey(call);
651
+ return this._active_tool_calls.has(key);
652
+ }
653
+
654
+ _trackActiveToolCall(call) {
655
+ const key = this._getToolCallKey(call);
656
+ this._active_tool_calls.set(key, {
657
+ call_id: call.id,
658
+ started_at: Date.now(),
659
+ function_name: call.function.name
660
+ });
661
+ _log('Tracking active tool call:', key);
662
+ }
663
+
664
+ _completeActiveToolCall(call) {
665
+ const key = this._getToolCallKey(call);
666
+ if (this._active_tool_calls.has(key)) {
667
+ this._active_tool_calls.delete(key);
668
+ _log('Completed active tool call:', key);
669
+ }
670
+ }
671
+
672
+ async _executeToolCallWithTimeout(call, handler, customTimeoutMs = null) {
673
+ const timeoutMs = customTimeoutMs || 5000;
674
+
675
+ return new Promise(async (resolve) => {
676
+ let timeoutId;
677
+ let completed = false;
678
+
679
+ timeoutId = setTimeout(() => {
680
+ if (!completed) {
681
+ completed = true;
682
+ _log('Tool call timed out after', timeoutMs + 'ms:', call.function.name);
683
+ resolve({
684
+ content: `Tool call "${call.function.name}" timed out after ${timeoutMs/1000} seconds.`,
685
+ functions: null
686
+ });
687
+ }
688
+ }, timeoutMs);
689
+
690
+ try {
691
+ const result = await this.interpretAndApplyChanges(call, handler);
692
+
693
+ if (!completed) {
694
+ completed = true;
695
+ clearTimeout(timeoutId);
696
+ resolve(result);
697
+ }
698
+ } catch (error) {
699
+ if (!completed) {
700
+ completed = true;
701
+ clearTimeout(timeoutId);
702
+ _lerr('Tool call failed with error:', call.function.name, error.message);
703
+ resolve({
704
+ content: `Tool call "${call.function.name}" failed with error: ${error.message}`,
705
+ functions: null
706
+ });
707
+ }
708
+ }
709
+ });
710
+ }
711
+
712
+ _validateToolResponses(msgs) {
713
+ const toolCallIds = new Set();
714
+ const toolResponseIds = new Set();
715
+
716
+ for (const msg of msgs) {
717
+ if (msg.tool_calls) {
718
+ for (const toolCall of msg.tool_calls)
719
+ toolCallIds.add(toolCall.id);
720
+ }
721
+ if (msg.role === 'tool' && msg.tool_call_id)
722
+ toolResponseIds.add(msg.tool_call_id);
723
+ }
724
+
725
+ const validatedMsgs = [];
726
+ const orphanedCalls = [];
727
+
728
+ for (const msg of msgs) {
729
+ if (msg.role === 'tool' && msg.tool_call_id) {
730
+ if (toolCallIds.has(msg.tool_call_id))
731
+ validatedMsgs.push(msg);
732
+ else
733
+ _log('Removing orphaned tool response with tool_call_id:', msg.tool_call_id);
734
+ } else if (msg.role === 'assistant' && msg.tool_calls) {
735
+ const validToolCalls = [];
736
+ for (const toolCall of msg.tool_calls) {
737
+ if (toolResponseIds.has(toolCall.id))
738
+ validToolCalls.push(toolCall);
739
+ else
740
+ orphanedCalls.push({
741
+ tool_call_id: toolCall.id,
742
+ function_name: toolCall.function?.name
743
+ });
744
+ }
745
+
746
+ if (validToolCalls.length > 0)
747
+ validatedMsgs.push({...msg, tool_calls: validToolCalls});
748
+ else if (msg.content && msg.content.trim() !== '') {
749
+ const cleanedMsg = {...msg};
750
+ delete cleanedMsg.tool_calls;
751
+ validatedMsgs.push(cleanedMsg);
752
+ }
753
+ } else {
754
+ validatedMsgs.push(msg);
755
+ }
756
+ }
757
+
758
+ if (orphanedCalls.length > 0)
759
+ _lerr('Removed tool calls without responses:', JSON.stringify(orphanedCalls, null, 2));
760
+
761
+ return validatedMsgs;
762
+ }
763
+
764
+ // Slice the last `limit` messages, walking back to avoid orphaning tool responses
765
+ // and expanding if fewer than MIN_CHAT_MESSAGES user/assistant messages are included.
766
+ _getQueueSlice(msgs, limit) {
767
+ if (msgs.length <= limit) return msgs;
768
+
769
+ let startIdx = msgs.length - limit;
770
+
771
+ // Walk backward if we'd start mid-tool-pair (tool response without its call)
772
+ while (startIdx > 0 && msgs[startIdx] && msgs[startIdx].role === 'tool')
773
+ startIdx--;
774
+
775
+ // Count user/assistant messages in the current window
776
+ let chatCount = 0;
777
+ for (let i = startIdx; i < msgs.length; i++) {
778
+ const role = msgs[i].role;
779
+ if (role === 'user' || role === 'assistant') chatCount++;
780
+ }
781
+
782
+ // Expand backward until we have at least MIN_CHAT_MESSAGES chat messages
783
+ while (chatCount < this.MIN_CHAT_MESSAGES && startIdx > 0) {
784
+ startIdx--;
785
+ const role = msgs[startIdx].role;
786
+ if (role === 'user' || role === 'assistant') chatCount++;
787
+ }
788
+
789
+ return msgs.slice(startIdx);
790
+ }
791
+
792
+ // Build message queue.
793
+ // When preamble is provided (by Saico orchestrator), it is prepended as-is
794
+ // and does NOT count against QUEUE_LIMIT. Otherwise falls back to standalone
795
+ // behavior: own prompt + tool digest + own messages.
796
+ _createMsgQ(preamble, add_tag, tag_filter) {
797
+ const fullQueue = [...(preamble || [])];
798
+
799
+ // Standalone fallback — when no preamble provided, add own prompt/digest
800
+ if (!preamble) {
801
+ if (this.prompt) {
802
+ const prompt = {role: 'system', content: this.prompt};
803
+ if (add_tag) prompt.tag = this.tag;
804
+ fullQueue.push(prompt);
805
+ }
806
+
807
+ if (this.tool_digest.length > 0) {
808
+ const digestText = this.tool_digest.map(entry =>
809
+ `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
810
+ ).join('\n');
811
+ fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
812
+ }
813
+ }
814
+
815
+ // Own messages — filter by tag if requested, then slice to QUEUE_LIMIT
816
+ // QUEUE_LIMIT only applies here — preamble is not counted
817
+ let my_msgs;
818
+ if (tag_filter !== undefined) {
819
+ my_msgs = this._msgs.filter(m => {
820
+ if (m.opts.summary) return true;
821
+ if (m.opts.tag === tag_filter) return true;
822
+ return false;
823
+ }).map(m => m.msg);
824
+ } else {
825
+ my_msgs = this.__msgs;
826
+ }
827
+
828
+ if (add_tag)
829
+ my_msgs = my_msgs.map(m => Object.assign({}, m, {tag: this.tag}));
830
+
831
+ fullQueue.push(...this._getQueueSlice(my_msgs, this.QUEUE_LIMIT));
832
+
833
+ return this._validateToolResponses(fullQueue);
834
+ }
835
+
836
+ async sendMessage(role, content, functions, opts) {
837
+ if (!content)
838
+ return console.error('trying to send a message with no content');
839
+
840
+ const isRecursiveCall = opts?._recursive_depth !== undefined;
841
+
842
+ if (this._sequential_mode && this._processing_sequential && !isRecursiveCall) {
843
+ _log('Sequential mode: queueing message:', role, content?.slice(0, 50));
844
+ return new Promise((resolve, reject) => {
845
+ this._sequential_queue.push({ role, content, functions, opts, resolve, reject });
846
+ });
847
+ }
848
+
849
+ return await this._sendMessageInternal(role, content, functions, opts);
850
+ }
851
+
852
+ async _sendMessageInternal(role, content, functions, opts) {
853
+ const isRecursiveCall = opts?._recursive_depth !== undefined;
854
+
855
+ if (!isRecursiveCall)
856
+ this._current_depth++;
857
+
858
+ const currentDepth = isRecursiveCall ? opts._recursive_depth : this._current_depth;
859
+
860
+ const wasProcessing = this._processing_sequential;
861
+ if (this._sequential_mode && !isRecursiveCall) {
862
+ _ldbg('[' + this.tag + '] _sendMessageInternal setting _processing_sequential = true');
863
+ this._processing_sequential = true;
864
+ }
865
+
866
+ try {
867
+ if (this._hasPendingToolCalls() && role !== 'tool') {
868
+ _log('Tool calls pending, queueing message:', role, content?.slice(0, 50));
869
+ this._waitingQueue.push({ role, content, functions, opts });
870
+ return { content: '', queued: true };
871
+ }
872
+
873
+ const o = this._createMsgObj(role, content, functions, opts);
874
+
875
+ if (role === 'tool' && opts?.tool_call_id)
876
+ this._insertToolResponseAtCorrectPosition(o, opts.tool_call_id);
877
+
878
+ return await this._processSendMessage(o, currentDepth);
879
+ } finally {
880
+ if (!isRecursiveCall) {
881
+ this._current_depth--;
882
+
883
+ if (this._sequential_mode) {
884
+ _ldbg('[' + this.tag + '] restoring _processing_sequential to:', wasProcessing);
885
+ this._processing_sequential = wasProcessing;
886
+ }
887
+
888
+ if (this._sequential_mode && !wasProcessing)
889
+ setImmediate(() => this._processSequentialQueue());
890
+ }
891
+ }
892
+ }
893
+
894
+ _debugQDump(Q, functions) {
895
+ if (util.is_mocha && process.env.PROD)
896
+ return;
897
+ const dbgQ = Q || this._createMsgQ(null, true);
898
+ if (debug) {
899
+ console.log('MSGQDEBUG - Q:', JSON.stringify(dbgQ.map(m => ({
900
+ role: m.role,
901
+ content: m.content?.substring?.(0, 50),
902
+ tool_calls: m.tool_calls,
903
+ tool_call_id: m.tool_call_id,
904
+ tag: m.tag
905
+ })), 0, 4), functions?.map?.(f => f.name));
906
+ }
907
+ }
908
+
909
+ async _processSendMessage(o, depth) {
910
+ let Q;
911
+ try {
912
+ const name = o.opts?.name;
913
+ _log('@@@@@@@@@ [>>(' + depth + ') ' + o.msgid + (o.opts?.tag ? '-' + o.opts.tag : '') +
914
+ (name ? ' F(' + name + ')' : '') + ' ] SEND-AI', o.msg.role,
915
+ o.msg.content.slice(0, 2000) + (o.msg.content?.length > 2000 ? '... ' : ''));
916
+
917
+ if (this._waitingQueue.length > 0 && !this._hasPendingToolCalls()) {
918
+ _log('Processing waiting queue before OpenAI call:', this._waitingQueue.length, 'messages');
919
+ this._processWaitingQueue();
920
+ }
921
+
922
+ Q = this._createMsgQ(o.opts?._preamble, false, o.opts?.tag);
923
+
924
+ // Use aggregated functions from Saico if provided, else fall back to own
925
+ let funcs;
926
+ if (o.opts?._aggregatedFunctions) {
927
+ const messageFuncs = o.functions || [];
928
+ funcs = [...o.opts._aggregatedFunctions, ...messageFuncs].length > 0
929
+ ? [...o.opts._aggregatedFunctions, ...messageFuncs]
930
+ : null;
931
+ } else {
932
+ const hierarchyFuncs = this.getFunctions() || [];
933
+ const messageFuncs = o.functions || [];
934
+ funcs = [...hierarchyFuncs, ...messageFuncs].length > 0
935
+ ? [...hierarchyFuncs, ...messageFuncs]
936
+ : null;
937
+ }
938
+
939
+ if (debug)
940
+ this._debugQDump(Q, funcs);
941
+
942
+ const reply = await openai.send(Q, funcs, o.opts?.model);
943
+
944
+ _log('@@@@@@@@@ [<<', o.msgid + (reply.tool_calls ? ' TC:' + (reply.tool_calls?.length || 0) : '') +
945
+ ' ] REPLY-AI', reply.role,
946
+ (reply.content && !o.opts?.noreply ? ' Content: ' + reply.content.slice(0, 2000) : '') +
947
+ (reply.content?.length > 2000 ? '... ' : '') +
948
+ (reply.tool_calls && !o.opts?.nofunc ? '\nCall tools ' + JSON.stringify(reply.tool_calls, 0, 4) : ''));
949
+
950
+ o.replied = 1;
951
+ delete o.functions;
952
+
953
+ if (o.opts?.nofunc)
954
+ delete reply.tool_calls;
955
+ if (o.opts?.debug_empty && !reply.content)
956
+ this._debugQDump(Q, o.functions);
957
+
958
+ this._msgs.push({msg: reply, msgid: o.msgid, opts: o.opts || {}, replied: 3});
959
+
960
+ let reply2 = {};
961
+ if (reply?.tool_calls) {
962
+ const toolNames = reply.tool_calls.map(call => call.function.name);
963
+ this._resetToolSequenceIfDifferent(toolNames);
964
+
965
+ const filteredToolCalls = this._filterExcessiveToolCalls(reply.tool_calls);
966
+
967
+ let toolCallsToProcess = filteredToolCalls;
968
+ let deferredToolCalls = [];
969
+
970
+ if (depth >= this.max_depth && filteredToolCalls.length > 0) {
971
+ _log('Max depth', this.max_depth, 'reached at depth', depth, ', deferring',
972
+ filteredToolCalls.length, 'tool calls');
973
+ deferredToolCalls = filteredToolCalls;
974
+ toolCallsToProcess = [];
975
+
976
+ this._deferred_tool_calls.push(...deferredToolCalls.map(call => ({
977
+ call,
978
+ originalMessage: o,
979
+ depth: depth
980
+ })));
981
+ }
982
+
983
+ const toolCallsWithResults = [];
984
+ for (const call of toolCallsToProcess) {
985
+ const toolName = call.function.name;
986
+ this._trackToolCall(toolName);
987
+
988
+ if (this._isDuplicateToolCall(call)) {
989
+ _log('Duplicate tool call detected:', call.function.name);
990
+ const result = {
991
+ content: `Duplicate call detected. An identical "${call.function.name}" ` +
992
+ `tool call with the same arguments is already running.`,
993
+ functions: null
994
+ };
995
+ toolCallsWithResults.push({ call, result, isDuplicate: true });
996
+ } else {
997
+ this._trackActiveToolCall(call);
998
+ toolCallsWithResults.push({ call, result: null, isDuplicate: false });
999
+ }
1000
+ }
1001
+
1002
+ for (const { call, isDuplicate } of toolCallsWithResults) {
1003
+ if (!isDuplicate) {
1004
+ const _snap = this.task
1005
+ ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
1006
+ try {
1007
+ const result = await this._executeToolCallWithTimeout(
1008
+ call, o.opts?.handler, o.opts?.timeout);
1009
+ const item = toolCallsWithResults.find(item => item.call.id === call.id);
1010
+ if (item) item.result = result;
1011
+ if (_snap !== null &&
1012
+ _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
1013
+ this._appendToolDigest(call.function.name, result?.content || '');
1014
+ } finally {
1015
+ this._completeActiveToolCall(call);
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ for (const [i, { call, result }] of toolCallsWithResults.entries()) {
1021
+ const opts = {
1022
+ name: call.function.name,
1023
+ tool_call_id: call.id,
1024
+ _recursive_depth: depth + 1,
1025
+ model: o.opts?.model
1026
+ };
1027
+ const content = result ? (result.content || result) : '';
1028
+ const functions = (i === toolCallsWithResults.length - 1 && result && result.functions)
1029
+ ? result.functions : null;
1030
+
1031
+ if (i === toolCallsWithResults.length - 1)
1032
+ reply2 = await this.sendMessage('tool', content, functions, opts);
1033
+ else {
1034
+ const toolResponse = this._createMsgObj('tool', content, null, opts);
1035
+ toolResponse.replied = 1;
1036
+ this._insertToolResponseAtCorrectPosition(toolResponse, call.id);
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ reply.content ||= '';
1042
+ reply.content += reply2?.content ? '\n' + reply2.content : '';
1043
+
1044
+ const hasPending = this._hasPendingToolCalls();
1045
+ const queueLength = this._waitingQueue.length;
1046
+
1047
+ if (!hasPending && queueLength > 0) {
1048
+ _log('No more pending tool calls, processing', queueLength, 'waiting messages');
1049
+ this._processWaitingQueue();
1050
+ }
1051
+
1052
+ const isRecursiveCall = o.opts?._recursive_depth !== undefined;
1053
+ if (!isRecursiveCall && this._current_depth === 1 && !this._hasPendingToolCalls()
1054
+ && this._waitingQueue.length === 0 && this._deferred_tool_calls.length > 0) {
1055
+ _log('Processing', this._deferred_tool_calls.length, 'deferred tool calls');
1056
+ await this._processDeferredToolCalls();
1057
+ }
1058
+
1059
+ return reply;
1060
+ } catch (err) {
1061
+ console.error('sendMessage error:', err);
1062
+ this._debugQDump(Q, o?.functions);
1063
+ throw err;
1064
+ }
1065
+ }
1066
+
1067
+ async interpretAndApplyChanges(call, handler) {
1068
+ _log('apply tool', call.function.name, 'have handler', !!handler, !!this.tool_handler);
1069
+ if (!call)
1070
+ return { content: '', functions: null };
1071
+
1072
+ _log('invoking function', call.function.name);
1073
+ handler ||= this.tool_handler;
1074
+ let result = await handler(call.function.name, call.function.arguments);
1075
+
1076
+ let content = result?.content || result || '';
1077
+ let functions = result?.functions || null;
1078
+
1079
+ if (content && typeof content !== 'string')
1080
+ content = JSON.stringify(content);
1081
+ else if (!content)
1082
+ {
1083
+ content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg `
1084
+ +`from the user`;
1085
+ }
1086
+
1087
+ _log('FUNCTION RESULT', call.function.name, call.id, content.substring(0, 50) + '...',
1088
+ functions ? 'with functions' : 'no functions');
1089
+ return { content, functions };
1090
+ }
1091
+
1092
+ // Spawn child context (creates a child task with its own context)
1093
+ spawnChild(prompt, tag, config = {}) {
1094
+ if (!this.task) {
1095
+ // If no task, create a standalone context (legacy mode)
1096
+ return createContext(prompt, null, { ...config, tag });
1097
+ }
1098
+
1099
+ // Create a child task with its own context
1100
+ const Itask = require('./itask.js');
1101
+ const childTask = new Itask({
1102
+ name: tag || 'child-context',
1103
+ prompt,
1104
+ async: true,
1105
+ spawn_parent: this.task,
1106
+ contextConfig: config
1107
+ }, []);
1108
+
1109
+ const childContext = new Context(prompt, childTask, { ...config, tag });
1110
+ childTask.setContext(childContext);
1111
+
1112
+ return childContext;
1113
+ }
1114
+ }
1115
+
1116
+ // Factory function to create a Context with Proxy wrapper
1117
+ function createContext(prompt, task, config = {}) {
1118
+ const instance = new Context(prompt, task, config);
1119
+
1120
+ return new Proxy(instance, {
1121
+ get(target, prop, receiver) {
1122
+ if (typeof prop === 'string' && !isNaN(prop)) {
1123
+ return target._msgs[Number(prop)]?.msg;
1124
+ }
1125
+
1126
+ if (typeof target._msgs[prop] === 'function') {
1127
+ return target[prop].bind(target);
1128
+ }
1129
+
1130
+ if (prop === 'length') {
1131
+ return target._msgs.length;
1132
+ }
1133
+
1134
+ return Reflect.get(target, prop, receiver);
1135
+ },
1136
+
1137
+ set(target, prop, value, receiver) {
1138
+ if (typeof prop === 'string' && !isNaN(prop)) {
1139
+ target._msgs[Number(prop)] = {msg: value};
1140
+ return true;
1141
+ }
1142
+
1143
+ return Reflect.set(target, prop, value, receiver);
1144
+ },
1145
+
1146
+ has(target, prop) {
1147
+ if (typeof prop === 'string' && !isNaN(prop)) return true;
1148
+ if (prop in target._msgs) return true;
1149
+ return prop in target;
1150
+ },
1151
+
1152
+ ownKeys(target) {
1153
+ const keys = Reflect.ownKeys(target);
1154
+ const msgKeys = Object.keys(target._msgs);
1155
+ return [...new Set([...msgKeys, ...keys])];
1156
+ },
1157
+
1158
+ getOwnPropertyDescriptor(target, prop) {
1159
+ if (typeof prop === 'string' && !isNaN(prop)) {
1160
+ return Object.getOwnPropertyDescriptor(target._msgs, prop);
1161
+ }
1162
+ return Object.getOwnPropertyDescriptor(target, prop);
1163
+ }
1164
+ });
1165
+ }
1166
+
1167
+ module.exports = { Context, createContext };