cowork-cli 2.2.1 → 2.3.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/package.json +1 -1
- package/src/engine/client.js +2 -1
- package/src/engine/models/BaseModel.js +85 -15
- package/src/engine/tools/webSearch.js +11 -12
package/package.json
CHANGED
package/src/engine/client.js
CHANGED
|
@@ -19,7 +19,8 @@ function clientLoader() {
|
|
|
19
19
|
return new OpenAI({
|
|
20
20
|
apiKey: config.model_api_key,
|
|
21
21
|
baseURL: baseURL,
|
|
22
|
-
timeout: 60000 // 60 seconds timeout
|
|
22
|
+
timeout: 60000, // 60 seconds timeout
|
|
23
|
+
maxRetries: 0, // Disable SDK's built-in retries to prevent overlapping attempts with BaseModel's retry loop.
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { toolDefinitions, dispatchTool } from '../tools/index.js';
|
|
2
|
-
import { logger } from '../../utils/logger.js';
|
|
2
|
+
import { logger, formatMain, formatDim } from '../../utils/logger.js';
|
|
3
3
|
import { ui } from '../../utils/ui.js';
|
|
4
4
|
import { outputFormatted } from '../../utils/outputFormatter.js';
|
|
5
5
|
|
|
6
|
+
// Defined at module scope: avoid re-allocating on every caught error.
|
|
7
|
+
// Transient Node.js-level network error codes that warrant an automatic retry.
|
|
8
|
+
const TRANSIENT_NET_CODES = new Set([
|
|
9
|
+
'ECONNRESET', // Connection forcibly closed by the remote side
|
|
10
|
+
'ETIMEDOUT', // Connection or operation timed out
|
|
11
|
+
'ECONNREFUSED', // Remote host actively refused the connection
|
|
12
|
+
'EAI_AGAIN', // Temporary DNS resolution failure
|
|
13
|
+
'ENETUNREACH', // Network is unreachable
|
|
14
|
+
'EHOSTUNREACH', // Host is unreachable
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Maximum delay (ms) the backoff is allowed to reach, regardless of retry count.
|
|
18
|
+
const MAX_BACKOFF_MS = 30000;
|
|
19
|
+
|
|
6
20
|
/**
|
|
7
21
|
* Base class for AI model interaction handlers.
|
|
8
22
|
* Encapsulates message history, API calling with retries, and robust tool execution.
|
|
@@ -18,6 +32,8 @@ export default class BaseModel {
|
|
|
18
32
|
this.messages = [];
|
|
19
33
|
this.maxTurns = 15; // Safeguard against infinite tool-calling loops
|
|
20
34
|
this.lastRequestTime = 0; // For proactive throttling
|
|
35
|
+
this._runStartTime = 0;
|
|
36
|
+
this._runUsage = { prompt: 0, completion: 0, total: 0 };
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
/**
|
|
@@ -36,6 +52,10 @@ export default class BaseModel {
|
|
|
36
52
|
* @param {string|null} systemPrompt Optional system-level instructions.
|
|
37
53
|
*/
|
|
38
54
|
async run(query, systemPrompt = null) {
|
|
55
|
+
// Reset per-run tracking state
|
|
56
|
+
this._runStartTime = performance.now();
|
|
57
|
+
this._runUsage = { prompt: 0, completion: 0, total: 0 };
|
|
58
|
+
|
|
39
59
|
if (systemPrompt) {
|
|
40
60
|
this.addMessage('system', systemPrompt);
|
|
41
61
|
}
|
|
@@ -50,7 +70,23 @@ export default class BaseModel {
|
|
|
50
70
|
const response = await this._getCompletion();
|
|
51
71
|
ui.stop();
|
|
52
72
|
|
|
53
|
-
|
|
73
|
+
// Guard against empty/null choices (content filter, provider quirks).
|
|
74
|
+
const choice = response.choices?.[0];
|
|
75
|
+
if (!choice?.message) {
|
|
76
|
+
logger.error("[API Error] Received empty or malformed response (no choices).");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const message = choice.message;
|
|
80
|
+
const finishReason = choice.finish_reason;
|
|
81
|
+
|
|
82
|
+
// Surface meaningful finish reasons to the user instead of silent behaviour.
|
|
83
|
+
if (finishReason === 'content_filter') {
|
|
84
|
+
logger.secondary("[System]: Response was blocked by the provider's content filter.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (finishReason === 'length') {
|
|
88
|
+
logger.secondary("[System]: Response was truncated due to token limits.");
|
|
89
|
+
}
|
|
54
90
|
|
|
55
91
|
// Let subclasses handle/format the response (e.g. Gemini thought signatures)
|
|
56
92
|
await this.handleResponse(message);
|
|
@@ -64,6 +100,7 @@ export default class BaseModel {
|
|
|
64
100
|
process.stdout.write("\n");
|
|
65
101
|
}
|
|
66
102
|
}
|
|
103
|
+
this._printStats();
|
|
67
104
|
return;
|
|
68
105
|
}
|
|
69
106
|
|
|
@@ -71,7 +108,9 @@ export default class BaseModel {
|
|
|
71
108
|
await this._processToolCalls(message.tool_calls);
|
|
72
109
|
|
|
73
110
|
} catch (err) {
|
|
74
|
-
ui.stop()
|
|
111
|
+
// Use ui.fail() (red dot) instead of ui.stop() (green dot) so the
|
|
112
|
+
// terminal reflects that the turn ended in error. fail() is a no-op when IDLE.
|
|
113
|
+
ui.fail();
|
|
75
114
|
// Deep error logging for API failures
|
|
76
115
|
if (err.status) {
|
|
77
116
|
logger.error(`[API Error] Status: ${err.status}`);
|
|
@@ -87,6 +126,7 @@ export default class BaseModel {
|
|
|
87
126
|
}
|
|
88
127
|
}
|
|
89
128
|
|
|
129
|
+
this._printStats();
|
|
90
130
|
logger.secondary("[System]: Reached maximum conversation turns. Ending session.");
|
|
91
131
|
}
|
|
92
132
|
|
|
@@ -118,39 +158,44 @@ export default class BaseModel {
|
|
|
118
158
|
|
|
119
159
|
// Update last request time on successful response
|
|
120
160
|
this.lastRequestTime = Date.now();
|
|
161
|
+
|
|
162
|
+
// Accumulate token usage across all turns (usage may be absent on some providers)
|
|
163
|
+
const u = response.usage;
|
|
164
|
+
if (u) {
|
|
165
|
+
this._runUsage.prompt += u.prompt_tokens ?? 0;
|
|
166
|
+
this._runUsage.completion += u.completion_tokens ?? 0;
|
|
167
|
+
this._runUsage.total += u.total_tokens ?? 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
121
170
|
return response;
|
|
122
171
|
|
|
123
172
|
} catch (err) {
|
|
124
173
|
// Transient HTTP status codes (rate-limit, server errors)
|
|
125
174
|
const isHttpTransient = [429, 500, 502, 503, 504].includes(err.status);
|
|
126
|
-
// Transient Node.js-level network errors (flaky connections, DNS hiccups)
|
|
127
|
-
const TRANSIENT_NET_CODES = new Set([
|
|
128
|
-
'ECONNRESET', // Connection forcibly closed by the remote side
|
|
129
|
-
'ETIMEDOUT', // Connection or operation timed out
|
|
130
|
-
'ECONNREFUSED', // Remote host actively refused the connection
|
|
131
|
-
'EAI_AGAIN', // Temporary DNS resolution failure
|
|
132
|
-
'ENETUNREACH', // Network is unreachable
|
|
133
|
-
'EHOSTUNREACH', // Host is unreachable
|
|
134
|
-
]);
|
|
135
175
|
const isNetTransient = TRANSIENT_NET_CODES.has(err.code);
|
|
136
176
|
const isTransient = isHttpTransient || isNetTransient;
|
|
137
177
|
|
|
138
178
|
if (isTransient && retries < maxRetries) {
|
|
139
179
|
retries++;
|
|
140
180
|
|
|
141
|
-
|
|
181
|
+
// Cap exponential delay at MAX_BACKOFF_MS to prevent unbounded wait times.
|
|
182
|
+
let delay = Math.min(Math.pow(2, retries) * 1000, MAX_BACKOFF_MS);
|
|
142
183
|
|
|
143
184
|
// 2. Adhere to Retry-After header if present (HTTP errors only)
|
|
144
185
|
const retryAfter = err.headers?.['retry-after'];
|
|
145
186
|
if (retryAfter) {
|
|
146
187
|
const seconds = parseInt(retryAfter);
|
|
147
188
|
if (!isNaN(seconds)) {
|
|
148
|
-
|
|
189
|
+
// Clamp to [1s, MAX_BACKOFF_MS] so a zero/negative header cannot bypass throttling
|
|
190
|
+
// and an absurd value cannot hang the process.
|
|
191
|
+
delay = Math.min(Math.max(seconds * 1000, 1000), MAX_BACKOFF_MS);
|
|
149
192
|
} else {
|
|
150
193
|
// Handle Date string format
|
|
151
194
|
const retryDate = new Date(retryAfter);
|
|
152
195
|
if (!isNaN(retryDate.getTime())) {
|
|
153
|
-
|
|
196
|
+
// Clamp — past dates become 1s, far-future dates are capped at MAX_BACKOFF_MS
|
|
197
|
+
// so the process can never hang indefinitely.
|
|
198
|
+
delay = Math.min(Math.max(retryDate.getTime() - Date.now(), 1000), MAX_BACKOFF_MS);
|
|
154
199
|
}
|
|
155
200
|
}
|
|
156
201
|
}
|
|
@@ -169,6 +214,11 @@ export default class BaseModel {
|
|
|
169
214
|
throw err;
|
|
170
215
|
}
|
|
171
216
|
}
|
|
217
|
+
|
|
218
|
+
// Exhaustion guard — the while loop should always return or throw before
|
|
219
|
+
// reaching here. If it doesn't (e.g. a future refactor breaks the invariant),
|
|
220
|
+
// surface an explicit error rather than returning undefined to the caller.
|
|
221
|
+
throw new Error('_getCompletion: retry loop exhausted without returning a response.');
|
|
172
222
|
}
|
|
173
223
|
|
|
174
224
|
/**
|
|
@@ -253,4 +303,24 @@ export default class BaseModel {
|
|
|
253
303
|
async handleResponse(message) {
|
|
254
304
|
this.messages.push(message);
|
|
255
305
|
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Prints a compact stats line: elapsed time and cumulative token usage.
|
|
309
|
+
* Only printed on clean exits (final answer or max-turns). Skipped on errors.
|
|
310
|
+
* Token counts are omitted silently if the provider did not return usage data.
|
|
311
|
+
* @private
|
|
312
|
+
*/
|
|
313
|
+
_printStats() {
|
|
314
|
+
const elapsed = ((performance.now() - this._runStartTime) / 1000).toFixed(2);
|
|
315
|
+
const { prompt, completion, total } = this._runUsage;
|
|
316
|
+
|
|
317
|
+
const timeStr = `${formatDim('time')} ${formatMain(elapsed + 's')}`;
|
|
318
|
+
|
|
319
|
+
// Only append token info when the provider actually returned usage data
|
|
320
|
+
const tokenStr = total > 0
|
|
321
|
+
? ` ${formatDim('·')} ${formatDim('tokens')} ${formatMain(String(total))} ${formatDim(`(${prompt}p/${completion}c)`)}`
|
|
322
|
+
: '';
|
|
323
|
+
|
|
324
|
+
ui.log(timeStr + tokenStr);
|
|
325
|
+
}
|
|
256
326
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser';
|
|
2
|
+
|
|
1
3
|
const TIMEOUT_MS = 10000;
|
|
2
4
|
const MAX_RESULTS_HARD_LIMIT = 20;
|
|
3
5
|
|
|
@@ -38,23 +40,20 @@ export default async function webSearch({ query, limit = 5 }) {
|
|
|
38
40
|
const html = await response.text();
|
|
39
41
|
const results = [];
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const blocks = html.split('class="links_main links_deep result__body"').slice(1);
|
|
43
|
+
const root = parse(html);
|
|
44
|
+
const resultNodes = root.querySelectorAll('.result__body');
|
|
44
45
|
|
|
45
|
-
for (const
|
|
46
|
+
for (const node of resultNodes) {
|
|
46
47
|
if (results.length >= maxLimit) break;
|
|
47
48
|
|
|
48
|
-
const
|
|
49
|
-
const
|
|
49
|
+
const titleEl = node.querySelector('.result__title a');
|
|
50
|
+
const snippetEl = node.querySelector('.result__snippet');
|
|
50
51
|
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const snippet = snippetMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
52
|
+
if (titleEl && snippetEl) {
|
|
53
|
+
const title = titleEl.textContent.replace(/\s+/g, ' ').trim();
|
|
54
|
+
const snippet = snippetEl.textContent.replace(/\s+/g, ' ').trim();
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
let url = snippetMatch[1];
|
|
56
|
+
let url = titleEl.getAttribute('href') || '';
|
|
58
57
|
if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
|
|
59
58
|
url = decodeURIComponent(url.split('uddg=')[1].split('&')[0]);
|
|
60
59
|
}
|