saico 2.3.0 → 2.5.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.
Files changed (7) hide show
  1. package/README.md +287 -297
  2. package/index.js +2 -9
  3. package/itask.js +16 -4
  4. package/msgs.js +106 -99
  5. package/package.json +1 -2
  6. package/saico.js +305 -41
  7. package/sid.js +0 -248
package/index.js CHANGED
@@ -12,15 +12,14 @@
12
12
  * - Storage persistence (Redis cache + optional DB backend)
13
13
  *
14
14
  * Main Components:
15
+ * - Saico: Master class (external users extend this)
15
16
  * - Itask: Base task class for all tasks (supports states, cancellation, promises)
16
17
  * - Context: Conversation context with message handling and tool calls
17
- * - Sid: Session root task (extends Itask, always has a context)
18
18
  * - Store: Storage abstraction layer (Redis + optional backends like DynamoDB)
19
19
  */
20
20
 
21
21
  const Itask = require('./itask.js');
22
22
  const { Context, createContext } = require('./msgs.js');
23
- const { Sid, createSid } = require('./sid.js');
24
23
  const { Store, DynamoBackend } = require('./store.js');
25
24
  const { Saico } = require('./saico.js');
26
25
  const { DynamoDBAdapter } = require('./dynamo.js');
@@ -55,7 +54,6 @@ async function init(config = {}) {
55
54
  * @param {Object|string} opt - Task options or name string
56
55
  * @param {string} opt.name - Task name
57
56
  * @param {string} opt.prompt - System prompt (if provided, creates a context)
58
- * @param {Function} opt.tool_handler - Tool handler function
59
57
  * @param {Array} opt.functions - Available functions for AI
60
58
  * @param {boolean} opt.cancel - Whether task is cancelable
61
59
  * @param {Object} opt.bind - Bind context for state functions
@@ -81,7 +79,6 @@ function createTask(opt, states = []) {
81
79
  token_limit: opt.token_limit,
82
80
  max_depth: opt.max_depth,
83
81
  max_tool_repetition: opt.max_tool_repetition,
84
- tool_handler: opt.tool_handler,
85
82
  functions: opt.functions,
86
83
  sequential_mode: opt.sequential_mode
87
84
  });
@@ -100,11 +97,10 @@ function createTask(opt, states = []) {
100
97
  * @param {string} tag - Context tag identifier
101
98
  * @param {number} token_limit - Token limit for summarization
102
99
  * @param {Array} msgs - Initial messages
103
- * @param {Function} tool_handler - Tool handler function
104
100
  * @param {Object} config - Additional configuration
105
101
  * @returns {Context} Proxied Context instance
106
102
  */
107
- function createQ(prompt, parent, tag, token_limit, msgs, tool_handler, config = {}) {
103
+ function createQ(prompt, parent, tag, token_limit, msgs, config = {}) {
108
104
  // For backward compatibility, if parent is a Context, get its task
109
105
  let task = null;
110
106
  if (parent && parent.task) {
@@ -115,7 +111,6 @@ function createQ(prompt, parent, tag, token_limit, msgs, tool_handler, config =
115
111
  tag,
116
112
  token_limit,
117
113
  msgs,
118
- tool_handler,
119
114
  ...config
120
115
  });
121
116
 
@@ -144,7 +139,6 @@ module.exports = {
144
139
  // Core classes
145
140
  Itask,
146
141
  Context,
147
- Sid,
148
142
  Store,
149
143
  DynamoBackend,
150
144
 
@@ -153,7 +147,6 @@ module.exports = {
153
147
 
154
148
  // Factory functions
155
149
  createTask,
156
- createSid,
157
150
  createContext,
158
151
 
159
152
  // Legacy compatibility
package/itask.js CHANGED
@@ -110,7 +110,6 @@ function Itask(opt, states){
110
110
  // Store options for context creation (prompt, functions, etc.)
111
111
  this.prompt = opt.prompt;
112
112
  this.functions = opt.functions;
113
- this.tool_handler = opt.tool_handler;
114
113
 
115
114
  // register root if no explicit spawn_parent provided
116
115
  // If opt.spawn_parent provided, spawn under it
@@ -746,9 +745,22 @@ Itask.prototype.closeContext = async function closeContext(){
746
745
  await this.context.close();
747
746
  };
748
747
 
749
- // Overridable: contextless tasks can provide a state summary that bubbles up
750
- // into the nearest ancestor context's _getStateSummary().
751
- Itask.prototype.getStateSummary = function getStateSummary(){ return ''; };
748
+ // Walk DOWN to find the deepest active descendant with a context
749
+ Itask.prototype.findDeepestContext = function findDeepestContext() {
750
+ let deepest = this.context ? { context: this.context, depth: 0 } : null;
751
+ const search = (task, depth) => {
752
+ for (const child of task.child) {
753
+ if (child._completed) continue;
754
+ if (child.context) {
755
+ if (!deepest || depth + 1 >= deepest.depth)
756
+ deepest = { context: child.context, depth: depth + 1 };
757
+ }
758
+ search(child, depth + 1);
759
+ }
760
+ };
761
+ search(this, 0);
762
+ return deepest ? deepest.context : null;
763
+ };
752
764
 
753
765
  // Reference to Context class (set by index.js to avoid circular dependency)
754
766
  Itask.Context = null;
package/msgs.js CHANGED
@@ -24,7 +24,6 @@ class Context {
24
24
  this.token_limit = config.token_limit || 1000000000;
25
25
  this.lower_limit = this.token_limit * 0.85;
26
26
  this.upper_limit = this.token_limit * 0.98;
27
- this.tool_handler = config.tool_handler || task?.tool_handler;
28
27
  this.functions = config.functions || task?.functions || null;
29
28
 
30
29
  // Recursive depth and repetition control
@@ -63,45 +62,10 @@ class Context {
63
62
  // Set the task reference (used when context is created separately)
64
63
  setTask(task) {
65
64
  this.task = task;
66
- if (!this.tool_handler)
67
- this.tool_handler = task?.tool_handler;
68
65
  if (!this.functions)
69
66
  this.functions = task?.functions;
70
67
  }
71
68
 
72
- // Overridable: extending classes provide current state summary
73
- getStateSummary() { return ''; }
74
-
75
- // Recursively collect state summaries from child tasks that have no context
76
- // (no msg Q), stopping at children that do have one.
77
- _collectChildStateSummaries(task) {
78
- if (!task.child || !task.child.size) return '';
79
- const parts = [];
80
- for (const child of task.child) {
81
- if (child.context) continue; // has its own Q — boundary, stop here
82
- if (typeof child.getStateSummary === 'function') {
83
- const s = child.getStateSummary();
84
- if (s) parts.push(s);
85
- }
86
- const nested = this._collectChildStateSummaries(child);
87
- if (nested) parts.push(nested);
88
- }
89
- return parts.join('\n');
90
- }
91
-
92
- // Internal (not overridable): own getStateSummary() + summaries from all
93
- // contextless descendants, stopping at the first child that has its own Q.
94
- _getStateSummary() {
95
- const parts = [];
96
- const own = this.getStateSummary();
97
- if (own) parts.push(own);
98
- if (this.task) {
99
- const childSummaries = this._collectChildStateSummaries(this.task);
100
- if (childSummaries) parts.push(childSummaries);
101
- }
102
- return parts.join('\n');
103
- }
104
-
105
69
  // Snapshot all public (non-underscore) task properties for dirty detection.
106
70
  // Mirrors the observable proxy convention: _ prefix = internal, ignored.
107
71
  // Does NOT call serialize() — that is for persistence, not dirty detection.
@@ -320,10 +284,9 @@ class Context {
320
284
 
321
285
  try {
322
286
  const correspondingDeferred = deferredGroup.find(d => d.call.id === call.id);
323
- const handler = correspondingDeferred?.originalMessage.opts.handler || this.tool_handler;
324
287
  const timeout = correspondingDeferred?.originalMessage.opts.timeout;
325
288
 
326
- result = await this._executeToolCallWithTimeout(call, handler, timeout);
289
+ result = await this._executeToolCallWithTimeout(call, timeout);
327
290
  if (_snap !== null &&
328
291
  _snap !== JSON.stringify(this._snapshotPublicProps(this.task)))
329
292
  this._appendToolDigest(call.function.name, result?.content || '');
@@ -702,7 +665,7 @@ class Context {
702
665
  }
703
666
  }
704
667
 
705
- async _executeToolCallWithTimeout(call, handler, customTimeoutMs = null) {
668
+ async _executeToolCallWithTimeout(call, customTimeoutMs = null) {
706
669
  const timeoutMs = customTimeoutMs || 5000;
707
670
 
708
671
  return new Promise(async (resolve) => {
@@ -721,7 +684,7 @@ class Context {
721
684
  }, timeoutMs);
722
685
 
723
686
  try {
724
- const result = await this.interpretAndApplyChanges(call, handler);
687
+ const result = await this.interpretAndApplyChanges(call);
725
688
 
726
689
  if (!completed) {
727
690
  completed = true;
@@ -822,52 +785,31 @@ class Context {
822
785
  return msgs.slice(startIdx);
823
786
  }
824
787
 
825
- // Build message queue — layered structure:
826
- // Layer 1: System prompts from ancestor hierarchy + own prompt (transient)
827
- // Layer 2: [State Summary] from getStateSummary() override (transient, if non-empty)
828
- // Layer 3: [Tool Activity Log] from tool_digest (transient, if non-empty)
829
- // Layer 4: Ancestor summaries only + last QUEUE_LIMIT of own messages (persistent)
830
- _createMsgQ(add_tag, tag_filter) {
831
- const fullQueue = [];
832
- const ancestorContexts = this.getAncestorContexts();
833
-
834
- // Layer 1+2: Each level's prompt followed immediately by its state summary
835
- for (const ctx of ancestorContexts) {
836
- if (ctx.prompt) {
837
- const prompt = {role: 'system', content: ctx.prompt};
838
- if (add_tag) prompt.tag = ctx.tag;
788
+ // Build message queue.
789
+ // When preamble is provided (by Saico orchestrator), it is prepended as-is
790
+ // and does NOT count against QUEUE_LIMIT. Otherwise falls back to standalone
791
+ // behavior: own prompt + tool digest + own messages.
792
+ _createMsgQ(preamble, add_tag, tag_filter) {
793
+ const fullQueue = [...(preamble || [])];
794
+
795
+ // Standalone fallback — when no preamble provided, add own prompt/digest
796
+ if (!preamble) {
797
+ if (this.prompt) {
798
+ const prompt = {role: 'system', content: this.prompt};
799
+ if (add_tag) prompt.tag = this.tag;
839
800
  fullQueue.push(prompt);
840
801
  }
841
- const ctxSummary = ctx._getStateSummary();
842
- if (ctxSummary)
843
- fullQueue.push({role: 'system', content: '[State Summary]\n' + ctxSummary});
844
- }
845
- if (this.prompt) {
846
- const prompt = {role: 'system', content: this.prompt};
847
- if (add_tag) prompt.tag = this.tag;
848
- fullQueue.push(prompt);
849
- }
850
- const stateSummary = this._getStateSummary();
851
- if (stateSummary)
852
- fullQueue.push({role: 'system', content: '[State Summary]\n' + stateSummary});
853
-
854
- // Layer 3: Tool digest (if non-empty)
855
- if (this.tool_digest.length > 0) {
856
- const digestText = this.tool_digest.map(entry =>
857
- `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
858
- ).join('\n');
859
- fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
860
- }
861
802
 
862
- // Layer 4: Ancestor summaries only (no full ancestor messages)
863
- for (const ctx of ancestorContexts) {
864
- const summaries = ctx._msgs
865
- .filter(m => m.opts.summary)
866
- .map(m => add_tag ? Object.assign({}, m.msg, {tag: ctx.tag}) : m.msg);
867
- fullQueue.push(...summaries);
803
+ if (this.tool_digest.length > 0) {
804
+ const digestText = this.tool_digest.map(entry =>
805
+ `[${new Date(entry.tm).toISOString()}] ${entry.tool}: ${entry.result}`
806
+ ).join('\n');
807
+ fullQueue.push({role: 'system', content: '[Tool Activity Log]\n' + digestText});
808
+ }
868
809
  }
869
810
 
870
811
  // Own messages — filter by tag if requested, then slice to QUEUE_LIMIT
812
+ // QUEUE_LIMIT only applies here — preamble is not counted
871
813
  let my_msgs;
872
814
  if (tag_filter !== undefined) {
873
815
  my_msgs = this._msgs.filter(m => {
@@ -948,7 +890,7 @@ class Context {
948
890
  _debugQDump(Q, functions) {
949
891
  if (util.is_mocha && process.env.PROD)
950
892
  return;
951
- const dbgQ = Q || this._createMsgQ(true);
893
+ const dbgQ = Q || this._createMsgQ(null, true);
952
894
  if (debug) {
953
895
  console.log('MSGQDEBUG - Q:', JSON.stringify(dbgQ.map(m => ({
954
896
  role: m.role,
@@ -973,14 +915,22 @@ class Context {
973
915
  this._processWaitingQueue();
974
916
  }
975
917
 
976
- Q = this._createMsgQ(false, o.opts?.tag);
918
+ Q = this._createMsgQ(o.opts?._preamble, false, o.opts?.tag);
977
919
 
978
- // Aggregate functions from hierarchy and merge with message-specific functions
979
- const hierarchyFuncs = this.getFunctions() || [];
980
- const messageFuncs = o.functions || [];
981
- const funcs = [...hierarchyFuncs, ...messageFuncs].length > 0
982
- ? [...hierarchyFuncs, ...messageFuncs]
983
- : null;
920
+ // Use aggregated functions from Saico if provided, else fall back to own
921
+ let funcs;
922
+ if (o.opts?._aggregatedFunctions) {
923
+ const messageFuncs = o.functions || [];
924
+ funcs = [...o.opts._aggregatedFunctions, ...messageFuncs].length > 0
925
+ ? [...o.opts._aggregatedFunctions, ...messageFuncs]
926
+ : null;
927
+ } else {
928
+ const hierarchyFuncs = this.getFunctions() || [];
929
+ const messageFuncs = o.functions || [];
930
+ funcs = [...hierarchyFuncs, ...messageFuncs].length > 0
931
+ ? [...hierarchyFuncs, ...messageFuncs]
932
+ : null;
933
+ }
984
934
 
985
935
  if (debug)
986
936
  this._debugQDump(Q, funcs);
@@ -1051,7 +1001,7 @@ class Context {
1051
1001
  ? JSON.stringify(this._snapshotPublicProps(this.task)) : null;
1052
1002
  try {
1053
1003
  const result = await this._executeToolCallWithTimeout(
1054
- call, o.opts?.handler, o.opts?.timeout);
1004
+ call, o.opts?.timeout);
1055
1005
  const item = toolCallsWithResults.find(item => item.call.id === call.id);
1056
1006
  if (item) item.result = result;
1057
1007
  if (_snap !== null &&
@@ -1110,27 +1060,84 @@ class Context {
1110
1060
  }
1111
1061
  }
1112
1062
 
1113
- async interpretAndApplyChanges(call, handler) {
1114
- _log('apply tool', call.function.name, 'have handler', !!handler, !!this.tool_handler);
1063
+ /**
1064
+ * Search the Saico hierarchy for a TOOL_<toolName> method.
1065
+ * Order: current task → walk UP parents → walk DOWN children (BFS).
1066
+ */
1067
+ _findToolImplementation(toolName) {
1068
+ const methodName = 'TOOL_' + toolName;
1069
+ const check = (task) =>
1070
+ task?._saico && typeof task._saico[methodName] === 'function' ? task._saico : null;
1071
+
1072
+ // 1. Current task
1073
+ let found = check(this.task);
1074
+ if (found) return { saico: found, methodName };
1075
+
1076
+ // 2. Walk UP parent chain
1077
+ let t = this.task?.parent;
1078
+ while (t) {
1079
+ found = check(t);
1080
+ if (found) return { saico: found, methodName };
1081
+ t = t.parent;
1082
+ }
1083
+
1084
+ // 3. Walk DOWN from this.task (BFS)
1085
+ if (this.task) {
1086
+ const queue = [...this.task.child];
1087
+ while (queue.length > 0) {
1088
+ const child = queue.shift();
1089
+ if (child._completed) continue;
1090
+ found = check(child);
1091
+ if (found) return { saico: found, methodName };
1092
+ if (child.child?.size > 0) queue.push(...child.child);
1093
+ }
1094
+ }
1095
+
1096
+ return null;
1097
+ }
1098
+
1099
+ async interpretAndApplyChanges(call) {
1115
1100
  if (!call)
1116
1101
  return { content: '', functions: null };
1117
1102
 
1118
- _log('invoking function', call.function.name);
1119
- handler ||= this.tool_handler;
1120
- let result = await handler(call.function.name, call.function.arguments);
1103
+ const toolName = call.function.name;
1104
+ _log('apply tool', toolName);
1105
+
1106
+ const impl = this._findToolImplementation(toolName);
1107
+ if (!impl) {
1108
+ _log('No TOOL_ method found for:', toolName);
1109
+ return {
1110
+ content: `Error: No implementation found for tool "${toolName}". ` +
1111
+ `Expected a TOOL_${toolName}(args) method on a Saico instance in the hierarchy.`,
1112
+ functions: null
1113
+ };
1114
+ }
1115
+
1116
+ _log('invoking TOOL_' + toolName, 'on', impl.saico.name || impl.saico.constructor.name);
1117
+
1118
+ let parsedArgs;
1119
+ try {
1120
+ parsedArgs = JSON.parse(call.function.arguments);
1121
+ } catch (e) {
1122
+ return {
1123
+ content: `Error: Failed to parse arguments for tool "${toolName}": ${e.message}`,
1124
+ functions: null
1125
+ };
1126
+ }
1127
+
1128
+ let result = await impl.saico[impl.methodName](parsedArgs);
1121
1129
 
1122
1130
  let content = result?.content || result || '';
1123
1131
  let functions = result?.functions || null;
1124
1132
 
1125
1133
  if (content && typeof content !== 'string')
1126
1134
  content = JSON.stringify(content);
1127
- else if (!content)
1128
- {
1129
- content = `tool call ${call.function.name} ${call.id} completed. do not reply. wait for the next msg `
1130
- +`from the user`;
1135
+ else if (!content) {
1136
+ content = `tool call ${toolName} ${call.id} completed. do not reply. wait for the next msg `
1137
+ + `from the user`;
1131
1138
  }
1132
1139
 
1133
- _log('FUNCTION RESULT', call.function.name, call.id, content.substring(0, 50) + '...',
1140
+ _log('FUNCTION RESULT', toolName, call.id, content.substring(0, 50) + '...',
1134
1141
  functions ? 'with functions' : 'no functions');
1135
1142
  return { content, functions };
1136
1143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saico",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "main": "index.js",
5
5
  "type": "commonjs",
6
6
  "description": "Hierarchical AI Conversation Orchestrator - Task hierarchy with conversation contexts",
@@ -17,7 +17,6 @@
17
17
  "itask.js",
18
18
  "context.js",
19
19
  "msgs.js",
20
- "sid.js",
21
20
  "saico.js",
22
21
  "dynamo.js",
23
22
  "openai.js",