scalix 1.2.0 → 1.3.2
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/dist/agent.js +57 -30
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +392 -167
- package/dist/commands.js +19 -2
- package/dist/constants.js +84 -3
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +86 -43
- package/dist/hooks.js +55 -5
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +117 -4
- package/dist/markdown.js +4 -1
- package/dist/model-router.js +11 -0
- package/dist/sandbox.js +73 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +6 -6
- package/dist/settings.js +53 -14
- package/dist/sub-agent.js +59 -2
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +42 -6
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ScalixClient } from './api-client.js';
|
|
1
|
+
import { ScalixClient, ApiError } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { getToolPermissionWithArgs } from './sandbox.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
6
|
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
|
|
7
7
|
import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
|
|
@@ -156,6 +156,8 @@ export class Agent extends EventEmitter {
|
|
|
156
156
|
groundingConfig = null;
|
|
157
157
|
groundingNudgeCount = 0;
|
|
158
158
|
retrievalConfig = null;
|
|
159
|
+
_sending = false;
|
|
160
|
+
outputFormatInstructionIndex = null;
|
|
159
161
|
constructor(config) {
|
|
160
162
|
super();
|
|
161
163
|
this.agentId = config.agentId ?? generateAgentId();
|
|
@@ -185,6 +187,7 @@ export class Agent extends EventEmitter {
|
|
|
185
187
|
circuitBreakerResetMs: config.circuitBreakerResetMs ?? AGENT_DEFAULTS.circuitBreakerResetMs,
|
|
186
188
|
fileRefTruncation: config.fileRefTruncation ?? TOOL_DEFAULTS.fileRefTruncation,
|
|
187
189
|
thinkingMode: config.thinkingMode ?? 'off',
|
|
190
|
+
workspaceRoot: config.workspaceRoot ?? process.cwd(),
|
|
188
191
|
};
|
|
189
192
|
this.contextManager = new ContextManager(config.contextManager);
|
|
190
193
|
if (config.dynamicToolSelection) {
|
|
@@ -252,22 +255,22 @@ export class Agent extends EventEmitter {
|
|
|
252
255
|
shouldFallbackToNext(err) {
|
|
253
256
|
const e = err;
|
|
254
257
|
const message = (e?.message || e?.error?.message || '').toLowerCase();
|
|
255
|
-
|
|
256
|
-
|
|
258
|
+
// Prefer the structured status carried by ApiError; fall back to other shapes.
|
|
259
|
+
const status = err instanceof ApiError ? err.status : (e?.status ?? e?.statusCode ?? e?.error?.code);
|
|
260
|
+
if (status === 401 || status === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
|
|
257
261
|
return false;
|
|
258
262
|
}
|
|
263
|
+
// Retry/fallback on transient upstream statuses.
|
|
264
|
+
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 408 || status === 404) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
259
267
|
return (message.includes('429') ||
|
|
260
268
|
message.includes('rate limit') ||
|
|
261
269
|
message.includes('provider returned error') ||
|
|
262
270
|
message.includes('safety') ||
|
|
263
271
|
message.includes('streaming failed') ||
|
|
264
272
|
message.includes('stream error') ||
|
|
265
|
-
message.includes('try again')
|
|
266
|
-
statusCode === 429 ||
|
|
267
|
-
statusCode === 500 ||
|
|
268
|
-
statusCode === 502 ||
|
|
269
|
-
statusCode === 503 ||
|
|
270
|
-
statusCode === 404);
|
|
273
|
+
message.includes('try again'));
|
|
271
274
|
}
|
|
272
275
|
isModelCircuitOpen(model) {
|
|
273
276
|
const state = this.modelFailures.get(model);
|
|
@@ -294,6 +297,10 @@ export class Agent extends EventEmitter {
|
|
|
294
297
|
const keepCount = Math.floor(this.config.maxMessages * this.config.messageKeepRatio);
|
|
295
298
|
const dropped = this.messages.length - keepCount;
|
|
296
299
|
this.messages = this.messages.slice(dropped);
|
|
300
|
+
// Drop leading orphan tool messages — a tool result whose preceding
|
|
301
|
+
// assistant tool_call was sliced away would be rejected by the API.
|
|
302
|
+
while (this.messages[0]?.role === 'tool')
|
|
303
|
+
this.messages.shift();
|
|
297
304
|
this.log.info(`Truncated ${dropped} oldest messages, keeping ${this.messages.length}`);
|
|
298
305
|
}
|
|
299
306
|
emitThinkingEnd() {
|
|
@@ -331,7 +338,7 @@ export class Agent extends EventEmitter {
|
|
|
331
338
|
model,
|
|
332
339
|
messages: apiMessages,
|
|
333
340
|
stream: false,
|
|
334
|
-
});
|
|
341
|
+
}, this._abortController?.signal);
|
|
335
342
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
336
343
|
if (!summary)
|
|
337
344
|
return 'Compaction failed: no summary generated.';
|
|
@@ -386,7 +393,7 @@ export class Agent extends EventEmitter {
|
|
|
386
393
|
{ role: 'user', content: prompt },
|
|
387
394
|
],
|
|
388
395
|
stream: false,
|
|
389
|
-
});
|
|
396
|
+
}, this._abortController?.signal);
|
|
390
397
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
391
398
|
if (summary) {
|
|
392
399
|
this.messages = [
|
|
@@ -451,6 +458,9 @@ export class Agent extends EventEmitter {
|
|
|
451
458
|
deregisterFromRegistry(status) {
|
|
452
459
|
if (!this.registeredInRegistry)
|
|
453
460
|
return;
|
|
461
|
+
// Reset so a fresh send() re-registers, and guard against double-deregister
|
|
462
|
+
// (e.g. an aborted turn followed by the send() catch/finally path).
|
|
463
|
+
this.registeredInRegistry = false;
|
|
454
464
|
AgentRegistry.getInstance().deregister(this.agentId, status);
|
|
455
465
|
}
|
|
456
466
|
checkInbox() {
|
|
@@ -540,7 +550,15 @@ export class Agent extends EventEmitter {
|
|
|
540
550
|
else {
|
|
541
551
|
instruction = 'IMPORTANT: Your final response MUST be valid JSON. Do not include any text outside the JSON object. No markdown code fences, no explanation — only the raw JSON.';
|
|
542
552
|
}
|
|
553
|
+
// Inject the format instruction only once per session — refresh the existing
|
|
554
|
+
// system message in place if it's still valid, otherwise append once.
|
|
555
|
+
const existing = this.outputFormatInstructionIndex;
|
|
556
|
+
if (existing !== null && this.messages[existing]?.role === 'system') {
|
|
557
|
+
this.messages[existing] = { role: 'system', content: instruction };
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
543
560
|
this.messages.push({ role: 'system', content: instruction });
|
|
561
|
+
this.outputFormatInstructionIndex = this.messages.length - 1;
|
|
544
562
|
}
|
|
545
563
|
extractJsonFromResponse(response) {
|
|
546
564
|
const trimmed = response.trim();
|
|
@@ -868,7 +886,28 @@ export class Agent extends EventEmitter {
|
|
|
868
886
|
continue;
|
|
869
887
|
}
|
|
870
888
|
}
|
|
871
|
-
|
|
889
|
+
// Parse tool arguments once, up front, so the permission decision,
|
|
890
|
+
// the confirmation prompt, and execution all see the same values.
|
|
891
|
+
let parsedArgs;
|
|
892
|
+
const rawArgs = (call.arguments ?? '').trim();
|
|
893
|
+
if (rawArgs === '') {
|
|
894
|
+
parsedArgs = {};
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
try {
|
|
898
|
+
parsedArgs = JSON.parse(call.arguments);
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
result = { error: 'Malformed JSON arguments — resend the tool call with valid JSON' };
|
|
902
|
+
reqLog.warn('Malformed tool arguments', { tool: call.name });
|
|
903
|
+
this.emit('tool:blocked', call.name, 'Malformed arguments');
|
|
904
|
+
this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
|
|
905
|
+
this.emit('tool:result', call.name, result);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// Args-aware permission so scoped allow/deny rules are enforced.
|
|
910
|
+
const permission = getToolPermissionWithArgs(call.name, this.config.permissionMode, parsedArgs);
|
|
872
911
|
if (permission === 'block') {
|
|
873
912
|
result = { denied: true, message: `Tool ${call.name} is blocked in ${this.config.permissionMode} mode` };
|
|
874
913
|
reqLog.info('Tool blocked by permission mode', { tool: call.name, mode: this.config.permissionMode });
|
|
@@ -884,8 +923,8 @@ export class Agent extends EventEmitter {
|
|
|
884
923
|
}
|
|
885
924
|
if (permission === 'ask') {
|
|
886
925
|
if (!this.config.confirmToolCall) {
|
|
887
|
-
if (this.config.permissionMode === '
|
|
888
|
-
reqLog.info('Auto-approving tool (no confirmation handler
|
|
926
|
+
if (this.config.permissionMode === 'yolo') {
|
|
927
|
+
reqLog.info('Auto-approving tool (yolo mode, no confirmation handler)', { tool: call.name });
|
|
889
928
|
}
|
|
890
929
|
else {
|
|
891
930
|
result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
|
|
@@ -902,13 +941,7 @@ export class Agent extends EventEmitter {
|
|
|
902
941
|
}
|
|
903
942
|
}
|
|
904
943
|
else {
|
|
905
|
-
|
|
906
|
-
try {
|
|
907
|
-
args = JSON.parse(call.arguments);
|
|
908
|
-
}
|
|
909
|
-
catch {
|
|
910
|
-
args = call.arguments;
|
|
911
|
-
}
|
|
944
|
+
const args = parsedArgs;
|
|
912
945
|
const CONFIRM_TIMEOUT_MS = 300_000;
|
|
913
946
|
const confirmed = await new Promise((resolve) => {
|
|
914
947
|
const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
|
|
@@ -940,20 +973,14 @@ export class Agent extends EventEmitter {
|
|
|
940
973
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
941
974
|
if (tool && typeof execFn === 'function') {
|
|
942
975
|
try {
|
|
943
|
-
|
|
944
|
-
try {
|
|
945
|
-
args = normalizeArgs(JSON.parse(call.arguments));
|
|
946
|
-
}
|
|
947
|
-
catch {
|
|
948
|
-
args = {};
|
|
949
|
-
}
|
|
976
|
+
const args = normalizeArgs(parsedArgs);
|
|
950
977
|
reqLog.debug('Executing tool', { tool: call.name });
|
|
951
978
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
952
979
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
953
980
|
: this.config.toolTimeoutMs;
|
|
954
981
|
result = await withTimeout(execFn(args, {
|
|
955
982
|
context: {},
|
|
956
|
-
workspaceRoot: process.cwd(),
|
|
983
|
+
workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
|
|
957
984
|
permissionMode: this.config.permissionMode,
|
|
958
985
|
agentId: this.agentId,
|
|
959
986
|
agentConfig: {
|
package/dist/api-client.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
body;
|
|
4
|
+
constructor(status, statusText, body) {
|
|
5
|
+
super(`API request failed: ${status} ${statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`);
|
|
6
|
+
this.name = 'ApiError';
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
1
12
|
export class ScalixClient {
|
|
2
13
|
baseURL;
|
|
3
14
|
apiKey;
|
|
4
|
-
|
|
15
|
+
timeoutMs;
|
|
16
|
+
constructor(baseURL, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
5
17
|
this.baseURL = baseURL.replace(/\/+$/, '');
|
|
6
18
|
this.apiKey = apiKey;
|
|
19
|
+
this.timeoutMs = timeoutMs;
|
|
7
20
|
}
|
|
8
21
|
async chatComplete(req, signal) {
|
|
9
22
|
const url = `${this.baseURL}/chat/completions`;
|
|
10
23
|
const body = JSON.stringify(req);
|
|
24
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
25
|
+
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
11
26
|
const response = await fetch(url, {
|
|
12
27
|
method: 'POST',
|
|
13
28
|
headers: {
|
|
@@ -16,11 +31,11 @@ export class ScalixClient {
|
|
|
16
31
|
'User-Agent': 'Scalix-Coder/1.0',
|
|
17
32
|
},
|
|
18
33
|
body,
|
|
19
|
-
signal,
|
|
34
|
+
signal: combined,
|
|
20
35
|
});
|
|
21
36
|
if (!response.ok) {
|
|
22
37
|
const text = await response.text().catch(() => '');
|
|
23
|
-
throw new
|
|
38
|
+
throw new ApiError(response.status, response.statusText, text);
|
|
24
39
|
}
|
|
25
40
|
if (req.stream) {
|
|
26
41
|
return this.parseSSEStream(response);
|
|
@@ -45,32 +60,52 @@ export class ScalixClient {
|
|
|
45
60
|
const trimmed = line.trim();
|
|
46
61
|
if (!trimmed || trimmed.startsWith(':'))
|
|
47
62
|
continue;
|
|
48
|
-
if (trimmed.startsWith('data:
|
|
49
|
-
const data = trimmed.slice(
|
|
63
|
+
if (trimmed.startsWith('data:')) {
|
|
64
|
+
const data = trimmed.slice(5).replace(/^ /, '');
|
|
50
65
|
if (data === '[DONE]')
|
|
51
66
|
return;
|
|
67
|
+
let parsed;
|
|
52
68
|
try {
|
|
53
|
-
|
|
69
|
+
parsed = JSON.parse(data);
|
|
54
70
|
}
|
|
55
71
|
catch {
|
|
56
72
|
// skip malformed chunks
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (parsed && parsed.error) {
|
|
76
|
+
const err = parsed.error;
|
|
77
|
+
throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
|
|
57
78
|
}
|
|
79
|
+
yield parsed;
|
|
58
80
|
}
|
|
59
81
|
}
|
|
60
82
|
}
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
const tail = buffer.trim();
|
|
84
|
+
if (tail.startsWith('data:')) {
|
|
85
|
+
const data = tail.slice(5).replace(/^ /, '');
|
|
63
86
|
if (data !== '[DONE]') {
|
|
87
|
+
let parsed;
|
|
64
88
|
try {
|
|
65
|
-
|
|
89
|
+
parsed = JSON.parse(data);
|
|
66
90
|
}
|
|
67
91
|
catch {
|
|
68
92
|
// skip
|
|
69
93
|
}
|
|
94
|
+
if (parsed) {
|
|
95
|
+
if (parsed.error) {
|
|
96
|
+
const err = parsed.error;
|
|
97
|
+
throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
|
|
98
|
+
}
|
|
99
|
+
yield parsed;
|
|
100
|
+
}
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
}
|
|
73
104
|
finally {
|
|
105
|
+
try {
|
|
106
|
+
await reader.cancel();
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
74
109
|
reader.releaseLock();
|
|
75
110
|
}
|
|
76
111
|
}
|
package/dist/auto-verify.js
CHANGED
|
@@ -74,7 +74,8 @@ async function detectChecker(filePath, projectRoot) {
|
|
|
74
74
|
}
|
|
75
75
|
// Verify the tool is available
|
|
76
76
|
try {
|
|
77
|
-
|
|
77
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
78
|
+
await execFileAsync(probe, [checker.command[0]], { timeout: 3000, shell: process.platform === 'win32' });
|
|
78
79
|
}
|
|
79
80
|
catch {
|
|
80
81
|
// Tool not installed, skip
|
|
@@ -100,6 +101,7 @@ export async function runVerification(filePath, projectRoot, config = VERIFY_DEF
|
|
|
100
101
|
cwd: projectRoot,
|
|
101
102
|
timeout: config.timeoutMs,
|
|
102
103
|
maxBuffer: 512 * 1024,
|
|
104
|
+
shell: process.platform === 'win32',
|
|
103
105
|
});
|
|
104
106
|
const output = (stderr || stdout).trim();
|
|
105
107
|
return {
|