wu-framework 1.1.8 → 1.1.9
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/LICENSE +19 -1
- package/README.md +227 -626
- package/dist/wu-framework.cjs.js +1 -1
- package/dist/wu-framework.cjs.js.map +1 -1
- package/dist/wu-framework.dev.js +2988 -1076
- package/dist/wu-framework.dev.js.map +1 -1
- package/dist/wu-framework.esm.js +1 -1
- package/dist/wu-framework.esm.js.map +1 -1
- package/dist/wu-framework.umd.js +1 -1
- package/dist/wu-framework.umd.js.map +1 -1
- package/package.json +10 -4
- package/src/adapters/react/index.js +51 -46
- package/src/ai/wu-ai-agent.js +546 -0
- package/src/ai/wu-ai-browser-primitives.js +354 -0
- package/src/ai/wu-ai-browser.js +29 -312
- package/src/ai/wu-ai-conversation.js +143 -84
- package/src/ai/wu-ai-orchestrate.js +1021 -0
- package/src/ai/wu-ai-provider.js +105 -10
- package/src/ai/wu-ai.js +338 -8
- package/src/core/wu-cache.js +1 -2
- package/src/core/wu-core.js +3 -4
- package/src/core/wu-mcp-bridge.js +198 -414
- package/src/core/wu-plugin.js +4 -1
- package/src/core/wu-style-bridge.js +23 -21
- package/src/index.js +25 -2
package/src/ai/wu-ai-browser.js
CHANGED
|
@@ -24,12 +24,17 @@
|
|
|
24
24
|
* // → includes browser_screenshot, browser_click, etc.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
import {
|
|
28
|
+
ensureInterceptors,
|
|
29
|
+
networkLog,
|
|
30
|
+
consoleLog,
|
|
31
|
+
captureScreenshot,
|
|
32
|
+
buildA11yTree,
|
|
33
|
+
clickElement,
|
|
34
|
+
typeIntoElement,
|
|
35
|
+
getFilteredNetwork,
|
|
36
|
+
getFilteredConsole,
|
|
37
|
+
} from './wu-ai-browser-primitives.js';
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
40
|
* Register all browser automation actions into a WuAI instance.
|
|
@@ -38,12 +43,7 @@ let interceptorsInstalled = false;
|
|
|
38
43
|
* @param {object} wu - The Wu Framework instance (window.wu)
|
|
39
44
|
*/
|
|
40
45
|
export function registerBrowserActions(ai, wu) {
|
|
41
|
-
|
|
42
|
-
if (!interceptorsInstalled) {
|
|
43
|
-
_installNetworkInterceptor();
|
|
44
|
-
_installConsoleInterceptor();
|
|
45
|
-
interceptorsInstalled = true;
|
|
46
|
-
}
|
|
46
|
+
ensureInterceptors();
|
|
47
47
|
|
|
48
48
|
// ════════════════════════════════════════════
|
|
49
49
|
// SCREENSHOT — Canvas API (SVG foreignObject)
|
|
@@ -58,66 +58,7 @@ export function registerBrowserActions(ai, wu) {
|
|
|
58
58
|
required: false,
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
|
-
handler: async (params) =>
|
|
62
|
-
const target = params.selector
|
|
63
|
-
? document.querySelector(params.selector)
|
|
64
|
-
: document.documentElement;
|
|
65
|
-
|
|
66
|
-
if (!target) return { error: `Element not found: ${params.selector}` };
|
|
67
|
-
|
|
68
|
-
const rect = target.getBoundingClientRect();
|
|
69
|
-
const w = Math.ceil(Math.min(rect.width || window.innerWidth, 1920));
|
|
70
|
-
const h = Math.ceil(Math.min(rect.height || window.innerHeight, 1080));
|
|
71
|
-
|
|
72
|
-
// Clone and inline styles for accurate rendering
|
|
73
|
-
const clone = target.cloneNode(true);
|
|
74
|
-
_inlineComputedStyles(target, clone);
|
|
75
|
-
|
|
76
|
-
const serializer = new XMLSerializer();
|
|
77
|
-
const xhtml = serializer.serializeToString(clone);
|
|
78
|
-
|
|
79
|
-
const svgStr = [
|
|
80
|
-
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">`,
|
|
81
|
-
'<foreignObject width="100%" height="100%">',
|
|
82
|
-
`<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;overflow:hidden;">`,
|
|
83
|
-
xhtml,
|
|
84
|
-
'</div>',
|
|
85
|
-
'</foreignObject>',
|
|
86
|
-
'</svg>',
|
|
87
|
-
].join('');
|
|
88
|
-
|
|
89
|
-
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
|
|
90
|
-
const url = URL.createObjectURL(svgBlob);
|
|
91
|
-
|
|
92
|
-
const dataUrl = await new Promise((resolve) => {
|
|
93
|
-
const img = new Image();
|
|
94
|
-
img.onload = () => {
|
|
95
|
-
const canvas = document.createElement('canvas');
|
|
96
|
-
canvas.width = w;
|
|
97
|
-
canvas.height = h;
|
|
98
|
-
const ctx = canvas.getContext('2d');
|
|
99
|
-
ctx.drawImage(img, 0, 0);
|
|
100
|
-
URL.revokeObjectURL(url);
|
|
101
|
-
resolve(canvas.toDataURL('image/png', 0.8));
|
|
102
|
-
};
|
|
103
|
-
img.onerror = () => {
|
|
104
|
-
URL.revokeObjectURL(url);
|
|
105
|
-
resolve(null);
|
|
106
|
-
};
|
|
107
|
-
img.src = url;
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
if (!dataUrl) return { error: 'Canvas rendering failed' };
|
|
111
|
-
|
|
112
|
-
const base64 = dataUrl.split(',')[1];
|
|
113
|
-
return {
|
|
114
|
-
width: w,
|
|
115
|
-
height: h,
|
|
116
|
-
format: 'png',
|
|
117
|
-
base64,
|
|
118
|
-
sizeKB: Math.round((base64.length * 3) / 4 / 1024),
|
|
119
|
-
};
|
|
120
|
-
},
|
|
61
|
+
handler: async (params) => captureScreenshot(params.selector),
|
|
121
62
|
permissions: [],
|
|
122
63
|
});
|
|
123
64
|
|
|
@@ -140,38 +81,11 @@ export function registerBrowserActions(ai, wu) {
|
|
|
140
81
|
},
|
|
141
82
|
},
|
|
142
83
|
handler: async (params, api) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
el = document.querySelector(params.selector);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (!el && params.text) {
|
|
150
|
-
const candidates = document.querySelectorAll(
|
|
151
|
-
'button, a, [role="button"], input[type="submit"], input[type="button"], [data-click], label, [onclick]'
|
|
152
|
-
);
|
|
153
|
-
const searchText = params.text.toLowerCase();
|
|
154
|
-
for (const candidate of candidates) {
|
|
155
|
-
if (candidate.textContent?.trim().toLowerCase().includes(searchText)) {
|
|
156
|
-
el = candidate;
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
84
|
+
const result = clickElement(params.selector, params.text);
|
|
85
|
+
if (!result.error) {
|
|
86
|
+
api.emit?.('browser:clicked', { selector: params.selector, text: params.text });
|
|
160
87
|
}
|
|
161
|
-
|
|
162
|
-
if (!el) return { error: `Element not found: ${params.selector || `text="${params.text}"`}` };
|
|
163
|
-
|
|
164
|
-
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
165
|
-
el.click();
|
|
166
|
-
|
|
167
|
-
const tag = el.tagName?.toLowerCase();
|
|
168
|
-
const id = el.id ? `#${el.id}` : '';
|
|
169
|
-
api.emit?.('browser:clicked', { selector: params.selector, text: params.text });
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
clicked: `${tag}${id}`,
|
|
173
|
-
text: el.textContent?.trim().slice(0, 100) || '',
|
|
174
|
-
};
|
|
88
|
+
return result;
|
|
175
89
|
},
|
|
176
90
|
permissions: ['emitEvents'],
|
|
177
91
|
});
|
|
@@ -205,50 +119,14 @@ export function registerBrowserActions(ai, wu) {
|
|
|
205
119
|
},
|
|
206
120
|
},
|
|
207
121
|
handler: async (params, api) => {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
el.value = '';
|
|
215
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Use native setter to trigger framework reactivity (React, Vue, etc.)
|
|
219
|
-
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
220
|
-
window.HTMLInputElement.prototype, 'value'
|
|
221
|
-
)?.set || Object.getOwnPropertyDescriptor(
|
|
222
|
-
window.HTMLTextAreaElement.prototype, 'value'
|
|
223
|
-
)?.set;
|
|
224
|
-
|
|
225
|
-
const newValue = params.clear ? params.text : (el.value || '') + params.text;
|
|
226
|
-
if (nativeSetter) {
|
|
227
|
-
nativeSetter.call(el, newValue);
|
|
228
|
-
} else {
|
|
229
|
-
el.value = newValue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
233
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
234
|
-
|
|
235
|
-
if (params.submit) {
|
|
236
|
-
const form = el.closest('form');
|
|
237
|
-
if (form) {
|
|
238
|
-
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
239
|
-
} else {
|
|
240
|
-
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
|
|
241
|
-
}
|
|
122
|
+
const result = typeIntoElement(params.selector, params.text, {
|
|
123
|
+
clear: params.clear,
|
|
124
|
+
submit: params.submit,
|
|
125
|
+
});
|
|
126
|
+
if (!result.error) {
|
|
127
|
+
api.emit?.('browser:typed', { selector: params.selector, length: params.text.length });
|
|
242
128
|
}
|
|
243
|
-
|
|
244
|
-
api.emit?.('browser:typed', { selector: params.selector, length: params.text.length });
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
selector: params.selector,
|
|
248
|
-
typed: params.text,
|
|
249
|
-
currentValue: el.value?.slice(0, 200),
|
|
250
|
-
submitted: !!params.submit,
|
|
251
|
-
};
|
|
129
|
+
return result;
|
|
252
130
|
},
|
|
253
131
|
permissions: ['emitEvents'],
|
|
254
132
|
});
|
|
@@ -378,7 +256,7 @@ export function registerBrowserActions(ai, wu) {
|
|
|
378
256
|
|
|
379
257
|
if (!target) return { error: `Element not found: ${params.selector}` };
|
|
380
258
|
|
|
381
|
-
const tree =
|
|
259
|
+
const tree = buildA11yTree(target, 0, params.depth || 5);
|
|
382
260
|
return { snapshot: tree };
|
|
383
261
|
},
|
|
384
262
|
permissions: [],
|
|
@@ -428,25 +306,7 @@ export function registerBrowserActions(ai, wu) {
|
|
|
428
306
|
required: false,
|
|
429
307
|
},
|
|
430
308
|
},
|
|
431
|
-
handler: async (params) =>
|
|
432
|
-
let filtered = networkLog;
|
|
433
|
-
if (params.method) {
|
|
434
|
-
filtered = filtered.filter((r) => r.method === params.method.toUpperCase());
|
|
435
|
-
}
|
|
436
|
-
if (params.status) {
|
|
437
|
-
if (params.status === 'error') {
|
|
438
|
-
filtered = filtered.filter((r) => r.status === 0 || r.status >= 400);
|
|
439
|
-
} else {
|
|
440
|
-
filtered = filtered.filter((r) => String(r.status).startsWith(params.status));
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
const limit = params.limit || 30;
|
|
444
|
-
return {
|
|
445
|
-
requests: filtered.slice(-limit),
|
|
446
|
-
total: networkLog.length,
|
|
447
|
-
showing: Math.min(filtered.length, limit),
|
|
448
|
-
};
|
|
449
|
-
},
|
|
309
|
+
handler: async (params) => getFilteredNetwork(params.method, params.status, params.limit),
|
|
450
310
|
permissions: [],
|
|
451
311
|
});
|
|
452
312
|
|
|
@@ -468,17 +328,7 @@ export function registerBrowserActions(ai, wu) {
|
|
|
468
328
|
required: false,
|
|
469
329
|
},
|
|
470
330
|
},
|
|
471
|
-
handler: async (params) =>
|
|
472
|
-
const filtered = params.level
|
|
473
|
-
? consoleLog.filter((m) => m.level === params.level)
|
|
474
|
-
: consoleLog;
|
|
475
|
-
const limit = params.limit || 30;
|
|
476
|
-
return {
|
|
477
|
-
messages: filtered.slice(-limit),
|
|
478
|
-
total: consoleLog.length,
|
|
479
|
-
showing: Math.min(filtered.length, limit),
|
|
480
|
-
};
|
|
481
|
-
},
|
|
331
|
+
handler: async (params) => getFilteredConsole(params.level, params.limit),
|
|
482
332
|
permissions: [],
|
|
483
333
|
});
|
|
484
334
|
|
|
@@ -526,138 +376,5 @@ export function registerBrowserActions(ai, wu) {
|
|
|
526
376
|
});
|
|
527
377
|
}
|
|
528
378
|
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
function _inlineComputedStyles(source, clone) {
|
|
532
|
-
const props = ['color', 'background', 'background-color', 'font-family',
|
|
533
|
-
'font-size', 'font-weight', 'border', 'border-radius', 'padding', 'margin',
|
|
534
|
-
'display', 'flex-direction', 'align-items', 'justify-content', 'gap',
|
|
535
|
-
'width', 'height', 'max-width', 'max-height', 'overflow', 'opacity',
|
|
536
|
-
'box-shadow', 'text-align', 'line-height', 'position', 'top', 'left',
|
|
537
|
-
'right', 'bottom', 'z-index', 'transform', 'visibility'];
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
const style = window.getComputedStyle(source);
|
|
541
|
-
for (const prop of props) {
|
|
542
|
-
const val = style.getPropertyValue(prop);
|
|
543
|
-
if (val) clone.style?.setProperty(prop, val);
|
|
544
|
-
}
|
|
545
|
-
} catch (_) { /* skip */ }
|
|
546
|
-
|
|
547
|
-
const srcKids = source.children || [];
|
|
548
|
-
const cloneKids = clone.children || [];
|
|
549
|
-
const max = Math.min(srcKids.length, cloneKids.length, 200);
|
|
550
|
-
for (let i = 0; i < max; i++) {
|
|
551
|
-
_inlineComputedStyles(srcKids[i], cloneKids[i]);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// ── Private: Build accessibility tree ──
|
|
556
|
-
|
|
557
|
-
function _buildA11yTree(el, depth, maxDepth) {
|
|
558
|
-
if (depth > maxDepth || !el) return '';
|
|
559
|
-
|
|
560
|
-
const indent = ' '.repeat(depth);
|
|
561
|
-
const tag = el.tagName?.toLowerCase() || '';
|
|
562
|
-
const role = el.getAttribute?.('role') || '';
|
|
563
|
-
const ariaLabel = el.getAttribute?.('aria-label') || '';
|
|
564
|
-
const text = el.childNodes?.length === 1 && el.childNodes[0].nodeType === 3
|
|
565
|
-
? el.textContent?.trim().slice(0, 80) : '';
|
|
566
|
-
|
|
567
|
-
let line = `${indent}<${tag}`;
|
|
568
|
-
if (el.id) line += ` id="${el.id}"`;
|
|
569
|
-
if (role) line += ` role="${role}"`;
|
|
570
|
-
if (ariaLabel) line += ` aria-label="${ariaLabel}"`;
|
|
571
|
-
if (el.className && typeof el.className === 'string') {
|
|
572
|
-
const cls = el.className.trim().slice(0, 60);
|
|
573
|
-
if (cls) line += ` class="${cls}"`;
|
|
574
|
-
}
|
|
575
|
-
line += '>';
|
|
576
|
-
if (text) line += ` "${text}"`;
|
|
577
|
-
|
|
578
|
-
let result = line + '\n';
|
|
579
|
-
const root = el.shadowRoot || el;
|
|
580
|
-
const children = root.children || [];
|
|
581
|
-
|
|
582
|
-
for (let i = 0; i < children.length && i < 50; i++) {
|
|
583
|
-
result += _buildA11yTree(children[i], depth + 1, maxDepth);
|
|
584
|
-
}
|
|
585
|
-
return result;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// ── Private: Network interceptor ──
|
|
589
|
-
|
|
590
|
-
function _installNetworkInterceptor() {
|
|
591
|
-
// Intercept fetch
|
|
592
|
-
const originalFetch = window.fetch;
|
|
593
|
-
window.fetch = async function (...args) {
|
|
594
|
-
const start = Date.now();
|
|
595
|
-
const req = args[0];
|
|
596
|
-
const url = typeof req === 'string' ? req : req?.url || '';
|
|
597
|
-
const method = (args[1]?.method || req?.method || 'GET').toUpperCase();
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
const response = await originalFetch.apply(window, args);
|
|
601
|
-
const size = parseInt(response.headers?.get('content-length') || '0', 10);
|
|
602
|
-
networkLog.push({
|
|
603
|
-
type: 'fetch', method, url,
|
|
604
|
-
status: response.status, statusText: response.statusText,
|
|
605
|
-
duration: Date.now() - start, size, timestamp: start,
|
|
606
|
-
});
|
|
607
|
-
if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
|
|
608
|
-
return response;
|
|
609
|
-
} catch (err) {
|
|
610
|
-
networkLog.push({
|
|
611
|
-
type: 'fetch', method, url,
|
|
612
|
-
status: 0, error: err.message,
|
|
613
|
-
duration: Date.now() - start, timestamp: start,
|
|
614
|
-
});
|
|
615
|
-
if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
|
|
616
|
-
throw err;
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
// Intercept XMLHttpRequest
|
|
621
|
-
const origOpen = XMLHttpRequest.prototype.open;
|
|
622
|
-
const origSend = XMLHttpRequest.prototype.send;
|
|
623
|
-
|
|
624
|
-
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
625
|
-
this._wuAi = { method: (method || 'GET').toUpperCase(), url: String(url) };
|
|
626
|
-
return origOpen.call(this, method, url, ...rest);
|
|
627
|
-
};
|
|
628
|
-
|
|
629
|
-
XMLHttpRequest.prototype.send = function (...args) {
|
|
630
|
-
if (this._wuAi) {
|
|
631
|
-
this._wuAi.start = Date.now();
|
|
632
|
-
this.addEventListener('loadend', () => {
|
|
633
|
-
networkLog.push({
|
|
634
|
-
type: 'xhr', method: this._wuAi.method, url: this._wuAi.url,
|
|
635
|
-
status: this.status, statusText: this.statusText,
|
|
636
|
-
duration: Date.now() - this._wuAi.start,
|
|
637
|
-
size: parseInt(this.getResponseHeader('content-length') || '0', 10),
|
|
638
|
-
timestamp: this._wuAi.start,
|
|
639
|
-
});
|
|
640
|
-
if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
|
|
641
|
-
});
|
|
642
|
-
}
|
|
643
|
-
return origSend.apply(this, args);
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// ── Private: Console interceptor ──
|
|
648
|
-
|
|
649
|
-
function _installConsoleInterceptor() {
|
|
650
|
-
const levels = ['log', 'warn', 'error'];
|
|
651
|
-
for (const level of levels) {
|
|
652
|
-
const original = console[level];
|
|
653
|
-
console[level] = (...args) => {
|
|
654
|
-
consoleLog.push({
|
|
655
|
-
level,
|
|
656
|
-
message: args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '),
|
|
657
|
-
timestamp: Date.now(),
|
|
658
|
-
});
|
|
659
|
-
if (consoleLog.length > MAX_CONSOLE_LOG) consoleLog.shift();
|
|
660
|
-
original.apply(console, args);
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
}
|
|
379
|
+
// All private helpers (buildA11yTree, inlineComputedStyles, interceptors)
|
|
380
|
+
// are now in wu-ai-browser-primitives.js — single source of truth.
|
|
@@ -26,6 +26,8 @@ const DEFAULT_CONFIG = {
|
|
|
26
26
|
systemPrompt: null, // string or function returning string
|
|
27
27
|
temperature: undefined,
|
|
28
28
|
maxTokens: undefined,
|
|
29
|
+
namespaceTTL: 30 * 60_000, // 30 min — auto-expire inactive namespaces (0 = disabled)
|
|
30
|
+
gcInterval: 5 * 60_000, // 5 min — how often to sweep for expired namespaces
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
// ─── Conversation Namespace ──────────────────────────────────────
|
|
@@ -90,6 +92,7 @@ export class WuAIConversation {
|
|
|
90
92
|
this._config = { ...DEFAULT_CONFIG };
|
|
91
93
|
this._namespaces = new Map();
|
|
92
94
|
this._activeRequests = new Map(); // namespace → promise
|
|
95
|
+
this._lastGcRun = Date.now();
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
/**
|
|
@@ -110,6 +113,7 @@ export class WuAIConversation {
|
|
|
110
113
|
* @param {object} [options.templateVars] - Variables for template interpolation
|
|
111
114
|
* @param {number} [options.temperature] - Override temperature
|
|
112
115
|
* @param {number} [options.maxTokens] - Override max tokens
|
|
116
|
+
* @param {string|object} [options.responseFormat] - Request JSON output ('json' or { type: 'json_schema', schema, name? })
|
|
113
117
|
* @param {AbortSignal} [options.signal] - External abort signal
|
|
114
118
|
* @returns {Promise<{ content: string, tool_results?: Array, usage?: object, namespace: string }>}
|
|
115
119
|
*/
|
|
@@ -158,6 +162,8 @@ export class WuAIConversation {
|
|
|
158
162
|
tools: tools.length > 0 ? tools : undefined,
|
|
159
163
|
temperature: options.temperature ?? this._config.temperature,
|
|
160
164
|
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
165
|
+
responseFormat: options.responseFormat,
|
|
166
|
+
provider: options.provider,
|
|
161
167
|
signal,
|
|
162
168
|
});
|
|
163
169
|
|
|
@@ -282,102 +288,113 @@ export class WuAIConversation {
|
|
|
282
288
|
this._permissions.rateLimiter.recordStart(ns.name);
|
|
283
289
|
|
|
284
290
|
try {
|
|
285
|
-
|
|
286
|
-
let
|
|
287
|
-
|
|
288
|
-
// Accumulator for streaming tool call deltas
|
|
289
|
-
const toolCallAccumulator = new Map(); // index → { id, name, args }
|
|
290
|
-
|
|
291
|
-
for await (const chunk of this._provider.stream(ns.getMessages(), {
|
|
292
|
-
tools: tools.length > 0 ? tools : undefined,
|
|
293
|
-
temperature: options.temperature ?? this._config.temperature,
|
|
294
|
-
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
295
|
-
signal,
|
|
296
|
-
})) {
|
|
297
|
-
if (chunk.type === 'text') {
|
|
298
|
-
fullContent += chunk.content;
|
|
299
|
-
yield chunk;
|
|
300
|
-
} else if (chunk.type === 'tool_call_start') {
|
|
301
|
-
// Start accumulating a new tool call
|
|
302
|
-
toolCallAccumulator.set(toolCallAccumulator.size, {
|
|
303
|
-
id: chunk.id,
|
|
304
|
-
name: chunk.name,
|
|
305
|
-
args: '',
|
|
306
|
-
});
|
|
307
|
-
} else if (chunk.type === 'tool_call_delta') {
|
|
308
|
-
// Accumulate arguments delta
|
|
309
|
-
const idx = chunk.index ?? (toolCallAccumulator.size - 1);
|
|
310
|
-
const acc = toolCallAccumulator.get(idx);
|
|
311
|
-
if (acc) {
|
|
312
|
-
if (chunk.id) acc.id = chunk.id;
|
|
313
|
-
if (chunk.name) acc.name = chunk.name;
|
|
314
|
-
acc.args += chunk.argumentsDelta || '';
|
|
315
|
-
} else {
|
|
316
|
-
// New tool call via OpenAI format
|
|
317
|
-
toolCallAccumulator.set(idx, {
|
|
318
|
-
id: chunk.id || `tc_${idx}`,
|
|
319
|
-
name: chunk.name || '',
|
|
320
|
-
args: chunk.argumentsDelta || '',
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
} else if (chunk.type === 'done') {
|
|
324
|
-
// If we accumulated tool calls, execute them
|
|
325
|
-
if (toolCallAccumulator.size > 0) {
|
|
326
|
-
const toolCalls = [];
|
|
327
|
-
for (const [, acc] of toolCallAccumulator) {
|
|
328
|
-
let parsedArgs = {};
|
|
329
|
-
try { parsedArgs = JSON.parse(acc.args); } catch { /* empty args */ }
|
|
330
|
-
toolCalls.push({ id: acc.id, name: acc.name, arguments: parsedArgs });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Add assistant message with tool calls
|
|
334
|
-
ns.addMessage({ role: 'assistant', content: fullContent, tool_calls: toolCalls });
|
|
291
|
+
// Tool call loop — mirrors send() behavior (up to maxToolRounds)
|
|
292
|
+
let rounds = 0;
|
|
293
|
+
const maxRounds = this._config.maxToolRounds;
|
|
335
294
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
});
|
|
295
|
+
while (rounds <= maxRounds) {
|
|
296
|
+
const tools = this._actions.getToolSchemas();
|
|
297
|
+
let fullContent = '';
|
|
298
|
+
const toolCallAccumulator = new Map(); // index → { id, name, args }
|
|
299
|
+
let streamEnded = false;
|
|
342
300
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
301
|
+
for await (const chunk of this._provider.stream(ns.getMessages(), {
|
|
302
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
303
|
+
temperature: options.temperature ?? this._config.temperature,
|
|
304
|
+
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
305
|
+
responseFormat: options.responseFormat,
|
|
306
|
+
provider: options.provider,
|
|
307
|
+
signal,
|
|
308
|
+
})) {
|
|
309
|
+
if (chunk.type === 'text') {
|
|
310
|
+
fullContent += chunk.content;
|
|
311
|
+
yield chunk;
|
|
312
|
+
} else if (chunk.type === 'tool_call_start') {
|
|
313
|
+
toolCallAccumulator.set(toolCallAccumulator.size, {
|
|
314
|
+
id: chunk.id,
|
|
315
|
+
name: chunk.name,
|
|
316
|
+
args: '',
|
|
317
|
+
});
|
|
318
|
+
} else if (chunk.type === 'tool_call_delta') {
|
|
319
|
+
const idx = chunk.index ?? (toolCallAccumulator.size - 1);
|
|
320
|
+
const acc = toolCallAccumulator.get(idx);
|
|
321
|
+
if (acc) {
|
|
322
|
+
if (chunk.id) acc.id = chunk.id;
|
|
323
|
+
if (chunk.name) acc.name = chunk.name;
|
|
324
|
+
acc.args += chunk.argumentsDelta || '';
|
|
325
|
+
} else {
|
|
326
|
+
toolCallAccumulator.set(idx, {
|
|
327
|
+
id: chunk.id || `tc_${idx}`,
|
|
328
|
+
name: chunk.name || '',
|
|
329
|
+
args: chunk.argumentsDelta || '',
|
|
354
330
|
});
|
|
355
331
|
}
|
|
356
|
-
|
|
332
|
+
} else if (chunk.type === 'done') {
|
|
333
|
+
streamEnded = true;
|
|
334
|
+
break; // exit inner loop, handle tool calls below
|
|
335
|
+
} else if (chunk.type === 'usage') {
|
|
336
|
+
yield chunk;
|
|
337
|
+
} else if (chunk.type === 'error') {
|
|
338
|
+
yield chunk;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
357
341
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// No tool calls — add assistant message and finish
|
|
342
|
+
// No tool calls → final response, we're done
|
|
343
|
+
if (toolCallAccumulator.size === 0) {
|
|
344
|
+
if (fullContent) {
|
|
362
345
|
ns.addMessage({ role: 'assistant', content: fullContent });
|
|
363
346
|
}
|
|
364
|
-
|
|
365
347
|
this._permissions.circuitBreaker.recordSuccess();
|
|
366
348
|
yield { type: 'done' };
|
|
367
349
|
return;
|
|
368
|
-
} else if (chunk.type === 'usage') {
|
|
369
|
-
yield chunk;
|
|
370
|
-
} else if (chunk.type === 'error') {
|
|
371
|
-
yield chunk;
|
|
372
350
|
}
|
|
373
|
-
}
|
|
374
351
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
352
|
+
// Tool calls detected — execute them
|
|
353
|
+
rounds++;
|
|
354
|
+
if (rounds > maxRounds) {
|
|
355
|
+
const msg = `[wu-ai] Tool call loop limit (${maxRounds}) reached in streaming namespace '${ns.name}'`;
|
|
356
|
+
logger.wuWarn(msg);
|
|
357
|
+
yield { type: 'error', error: msg };
|
|
358
|
+
yield { type: 'done' };
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Parse accumulated tool calls
|
|
363
|
+
const toolCalls = [];
|
|
364
|
+
for (const [, acc] of toolCallAccumulator) {
|
|
365
|
+
let parsedArgs = {};
|
|
366
|
+
try { parsedArgs = JSON.parse(acc.args); } catch { /* empty args */ }
|
|
367
|
+
toolCalls.push({ id: acc.id, name: acc.name, arguments: parsedArgs });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Add assistant message with tool calls to history
|
|
371
|
+
ns.addMessage({ role: 'assistant', content: fullContent, tool_calls: toolCalls });
|
|
372
|
+
|
|
373
|
+
// Execute each tool call
|
|
374
|
+
this._permissions.loopProtection.enter(traceId);
|
|
375
|
+
for (const tc of toolCalls) {
|
|
376
|
+
const result = await this._actions.execute(tc.name, tc.arguments, {
|
|
377
|
+
traceId, depth: rounds, callId: tc.id,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
yield {
|
|
381
|
+
type: 'tool_result',
|
|
382
|
+
tool: tc.name,
|
|
383
|
+
result: result.success ? result.result : { error: result.reason },
|
|
384
|
+
success: result.success,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
ns.addMessage({
|
|
388
|
+
role: 'tool',
|
|
389
|
+
content: JSON.stringify(result.success ? result.result : { error: result.reason }),
|
|
390
|
+
tool_call_id: tc.id,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
this._permissions.loopProtection.exit(traceId);
|
|
394
|
+
|
|
395
|
+
yield { type: 'tool_calls_done', count: toolCalls.length };
|
|
396
|
+
// Loop continues → re-stream with tool results in history
|
|
378
397
|
}
|
|
379
|
-
this._permissions.circuitBreaker.recordSuccess();
|
|
380
|
-
yield { type: 'done' };
|
|
381
398
|
|
|
382
399
|
} catch (err) {
|
|
383
400
|
this._permissions.circuitBreaker.recordFailure();
|
|
@@ -473,10 +490,52 @@ export class WuAIConversation {
|
|
|
473
490
|
|
|
474
491
|
_getOrCreateNamespace(name) {
|
|
475
492
|
const nsName = name || this._config.defaultNamespace;
|
|
493
|
+
|
|
494
|
+
// Lazy GC sweep — only runs periodically, not on every access
|
|
495
|
+
this._maybeGcSweep();
|
|
496
|
+
|
|
476
497
|
if (!this._namespaces.has(nsName)) {
|
|
477
498
|
this._namespaces.set(nsName, new ConversationNamespace(nsName));
|
|
478
499
|
}
|
|
479
|
-
|
|
500
|
+
const ns = this._namespaces.get(nsName);
|
|
501
|
+
ns.lastActivity = Date.now();
|
|
502
|
+
return ns;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Sweep expired namespaces. Called lazily on access, throttled by gcInterval.
|
|
507
|
+
* Never deletes the default namespace or namespaces with active requests.
|
|
508
|
+
*/
|
|
509
|
+
_maybeGcSweep() {
|
|
510
|
+
const ttl = this._config.namespaceTTL;
|
|
511
|
+
const interval = this._config.gcInterval;
|
|
512
|
+
if (!ttl || ttl <= 0) return; // GC disabled
|
|
513
|
+
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
if (now - this._lastGcRun < interval) return; // Not time yet
|
|
516
|
+
this._lastGcRun = now;
|
|
517
|
+
|
|
518
|
+
const cutoff = now - ttl;
|
|
519
|
+
const toDelete = [];
|
|
520
|
+
|
|
521
|
+
for (const [name, ns] of this._namespaces) {
|
|
522
|
+
// Never GC the default namespace
|
|
523
|
+
if (name === this._config.defaultNamespace) continue;
|
|
524
|
+
// Don't GC namespaces with active abort controllers (in-flight request)
|
|
525
|
+
if (ns._abortController) continue;
|
|
526
|
+
// Expired?
|
|
527
|
+
if (ns.lastActivity < cutoff) {
|
|
528
|
+
toDelete.push(name);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const name of toDelete) {
|
|
533
|
+
this._namespaces.delete(name);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (toDelete.length > 0) {
|
|
537
|
+
logger.wuDebug(`[wu-ai] GC sweep: removed ${toDelete.length} expired namespace(s): ${toDelete.join(', ')}`);
|
|
538
|
+
}
|
|
480
539
|
}
|
|
481
540
|
|
|
482
541
|
async _buildSystemPrompt(options) {
|