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.
@@ -24,12 +24,17 @@
24
24
  * // → includes browser_screenshot, browser_click, etc.
25
25
  */
26
26
 
27
- // ── Shared capture buffers ──
28
- const networkLog = [];
29
- const MAX_NETWORK_LOG = 300;
30
- const consoleLog = [];
31
- const MAX_CONSOLE_LOG = 500;
32
- let interceptorsInstalled = false;
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
- // Install interceptors only once
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
- let el = null;
144
-
145
- if (params.selector) {
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 el = document.querySelector(params.selector);
209
- if (!el) return { error: `Element not found: ${params.selector}` };
210
-
211
- el.focus();
212
-
213
- if (params.clear) {
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 = _buildA11yTree(target, 0, params.depth || 5);
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
- // ── Private: Inline computed styles for Canvas rendering ──
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
- const tools = this._actions.getToolSchemas();
286
- let fullContent = '';
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
- // Execute each tool call
337
- this._permissions.loopProtection.enter(traceId);
338
- for (const tc of toolCalls) {
339
- const result = await this._actions.execute(tc.name, tc.arguments, {
340
- traceId, depth: 1, callId: tc.id,
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
- yield {
344
- type: 'tool_result',
345
- tool: tc.name,
346
- result: result.success ? result.result : { error: result.reason },
347
- success: result.success,
348
- };
349
-
350
- ns.addMessage({
351
- role: 'tool',
352
- content: JSON.stringify(result.success ? result.result : { error: result.reason }),
353
- tool_call_id: tc.id,
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
- this._permissions.loopProtection.exit(traceId);
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
- // After tool execution in streaming, yield done — caller can re-stream if needed
359
- yield { type: 'tool_calls_done', count: toolCalls.length };
360
- } else {
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
- // Stream ended without explicit 'done' chunk
376
- if (fullContent) {
377
- ns.addMessage({ role: 'assistant', content: fullContent });
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
- return this._namespaces.get(nsName);
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) {