scalix 1.4.1 → 1.5.1
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/CHANGELOG.md +60 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +319 -26
- package/dist/api-client.js +159 -26
- package/dist/cli.js +671 -252
- package/dist/cloud.js +94 -5
- package/dist/constants.js +96 -10
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +44 -20
- package/dist/ipc-server.js +63 -12
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +117 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/dist/agent.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ApiError } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { getToolPermissionWithArgs } from './sandbox.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
-
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
|
|
7
|
-
import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
|
|
6
|
+
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
|
|
7
|
+
import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
|
|
8
8
|
import { ContextManager } from './context-manager.js';
|
|
9
9
|
import { zodToJsonSchema } from './tool-helper.js';
|
|
10
10
|
import { ToolSelector } from './tool-selector.js';
|
|
@@ -19,7 +19,15 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
|
|
|
19
19
|
import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
|
|
20
20
|
import fs from 'node:fs/promises';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { fetchCloudTools, callCloudTool } from './cloud.js';
|
|
22
|
+
import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
|
|
23
|
+
import { createChatClient, capabilitiesFor } from './providers/index.js';
|
|
24
|
+
/**
|
|
25
|
+
* Marks an error that Agent has already announced through the 'error' event.
|
|
26
|
+
* `send()` emits and then rethrows, so a caller that also prints from its catch
|
|
27
|
+
* block renders every failure twice — once with the model name and a recovery
|
|
28
|
+
* hint, then again bare. Callers should check this before printing.
|
|
29
|
+
*/
|
|
30
|
+
export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
|
|
23
31
|
function transformToolsForApi(tools) {
|
|
24
32
|
return tools
|
|
25
33
|
// type:'mcp' entries are decorative config for user-registered external
|
|
@@ -137,6 +145,8 @@ export class Agent extends EventEmitter {
|
|
|
137
145
|
modelIndex = 0;
|
|
138
146
|
log;
|
|
139
147
|
contextManager;
|
|
148
|
+
/** The context window the user configured, if any. See syncContextLimit(). */
|
|
149
|
+
configuredContextTokens;
|
|
140
150
|
toolSelector = null;
|
|
141
151
|
_aborted = false;
|
|
142
152
|
_thinkingActive = false;
|
|
@@ -165,12 +175,30 @@ export class Agent extends EventEmitter {
|
|
|
165
175
|
outputFormatInstructionIndex = null;
|
|
166
176
|
cloudProjectId;
|
|
167
177
|
cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
178
|
+
providerId;
|
|
179
|
+
capabilities;
|
|
180
|
+
cloudApiKey;
|
|
181
|
+
cloudBaseUrl;
|
|
182
|
+
streamingToolCallsResolved;
|
|
168
183
|
constructor(config) {
|
|
169
184
|
super();
|
|
170
185
|
this.agentId = config.agentId ?? generateAgentId();
|
|
171
186
|
this.parentAgentId = config.parentAgentId ?? null;
|
|
172
187
|
this.agentDescription = config.agentDescription ?? 'main';
|
|
173
|
-
this.
|
|
188
|
+
this.providerId = config.provider ?? 'scalix';
|
|
189
|
+
this.capabilities = capabilitiesFor(this.providerId);
|
|
190
|
+
if (this.providerId !== 'scalix' && !config.baseUrl) {
|
|
191
|
+
throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
|
|
192
|
+
}
|
|
193
|
+
this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
|
|
194
|
+
// Cloud tools follow their own credential pair. On the Scalix gateway the
|
|
195
|
+
// inference creds double as the cloud creds; on BYOK `config.baseUrl` is a
|
|
196
|
+
// third-party host with no /v1/me, so the cloud URL falls back to the
|
|
197
|
+
// Scalix default rather than inheriting it.
|
|
198
|
+
this.cloudApiKey = config.cloudApiKey?.trim()
|
|
199
|
+
|| (this.capabilities.platformTools ? config.apiKey : '');
|
|
200
|
+
this.cloudBaseUrl = config.cloudBaseUrl?.trim()
|
|
201
|
+
|| (this.capabilities.platformTools ? (config.baseUrl || DEFAULT_BASE_URL) : DEFAULT_BASE_URL);
|
|
174
202
|
this.log = logger.child();
|
|
175
203
|
const resolvedModels = Array.isArray(config.model)
|
|
176
204
|
? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
|
|
@@ -198,6 +226,9 @@ export class Agent extends EventEmitter {
|
|
|
198
226
|
};
|
|
199
227
|
this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
|
|
200
228
|
this.contextManager = new ContextManager(config.contextManager);
|
|
229
|
+
// Kept separately because syncContextLimit() overwrites the manager's own
|
|
230
|
+
// value on every read and would otherwise have nothing to fall back to.
|
|
231
|
+
this.configuredContextTokens = config.contextManager?.maxContextTokens;
|
|
201
232
|
if (config.dynamicToolSelection) {
|
|
202
233
|
this.toolSelector = new ToolSelector();
|
|
203
234
|
this.toolSelector.index(this.config.tools);
|
|
@@ -214,6 +245,7 @@ export class Agent extends EventEmitter {
|
|
|
214
245
|
setAgentToolConfig({
|
|
215
246
|
apiKey: config.apiKey,
|
|
216
247
|
baseUrl: config.baseUrl,
|
|
248
|
+
provider: this.providerId,
|
|
217
249
|
models: this.config.models,
|
|
218
250
|
permissionMode: this.config.permissionMode,
|
|
219
251
|
confirmToolCall: this.config.confirmToolCall,
|
|
@@ -246,7 +278,16 @@ export class Agent extends EventEmitter {
|
|
|
246
278
|
this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
|
|
247
279
|
}
|
|
248
280
|
}
|
|
249
|
-
|
|
281
|
+
/**
|
|
282
|
+
* `fresh` — a NEW user turn: always start from the front of the list (the
|
|
283
|
+
* user's chosen model). The idx+1 bookkeeping below exists so RETRIES
|
|
284
|
+
* advance to the next model in the chain; without the fresh reset it also
|
|
285
|
+
* rotated the STARTING model, so with a failover chain configured,
|
|
286
|
+
* consecutive sends silently alternated models — a switch never "stuck".
|
|
287
|
+
*/
|
|
288
|
+
pickModel(fresh = false) {
|
|
289
|
+
if (fresh)
|
|
290
|
+
this.modelIndex = 0;
|
|
250
291
|
const startIdx = this.modelIndex;
|
|
251
292
|
for (let i = 0; i < this.config.models.length; i++) {
|
|
252
293
|
const idx = (startIdx + i) % this.config.models.length;
|
|
@@ -260,6 +301,39 @@ export class Agent extends EventEmitter {
|
|
|
260
301
|
this.modelIndex = (startIdx + 1) % this.config.models.length;
|
|
261
302
|
return this.config.models[startIdx % this.config.models.length];
|
|
262
303
|
}
|
|
304
|
+
/** Provider-scoped max_tokens policy. Scalix: always send, clamped to the
|
|
305
|
+
* platform cap (omitting it lets the platform apply a much smaller default
|
|
306
|
+
* and truncate the answer mid-sentence). OpenAI-compatible BYOK: send only a
|
|
307
|
+
* user/API-registered ceiling — generic endpoints reject values above the
|
|
308
|
+
* target model's real limit. */
|
|
309
|
+
maxTokensField(model) {
|
|
310
|
+
if (this.capabilities.maxTokensPolicy === 'clamped') {
|
|
311
|
+
return { max_tokens: maxOutputTokensForRequest(model) };
|
|
312
|
+
}
|
|
313
|
+
return hasExplicitModelSpec(model)
|
|
314
|
+
? { max_tokens: getModelSpec(model).maxOutputTokens }
|
|
315
|
+
: {};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether an agentic turn can stream against this endpoint. BYOK endpoints
|
|
319
|
+
* speak the OpenAI wire and always stream tool-call deltas, so the static
|
|
320
|
+
* capability answers for them. The Scalix path is a version question, not a
|
|
321
|
+
* provider one: routers that predate streamed tool calls drop `tools` on the
|
|
322
|
+
* stream and answer in prose, and a published CLI does not choose which
|
|
323
|
+
* deployment it is pointed at. There is no version header on the inference
|
|
324
|
+
* path, so it asks /models, which advertises what the deployment can do.
|
|
325
|
+
* Resolved once per session; an unreachable or older endpoint answers "no"
|
|
326
|
+
* and the turn goes non-streaming, exactly as before.
|
|
327
|
+
*/
|
|
328
|
+
async streamsToolCalls() {
|
|
329
|
+
if (this.capabilities.streamingToolCalls)
|
|
330
|
+
return true;
|
|
331
|
+
if (this.streamingToolCallsResolved === undefined) {
|
|
332
|
+
const caps = await this.client.endpointCapabilities();
|
|
333
|
+
this.streamingToolCallsResolved = caps.streaming_tool_calls === true;
|
|
334
|
+
}
|
|
335
|
+
return this.streamingToolCallsResolved;
|
|
336
|
+
}
|
|
263
337
|
shouldFallbackToNext(err) {
|
|
264
338
|
const e = err;
|
|
265
339
|
const message = (e?.message || e?.error?.message || '').toLowerCase();
|
|
@@ -328,6 +402,20 @@ export class Agent extends EventEmitter {
|
|
|
328
402
|
setInstructions(instructions) { this.config.instructions = instructions; }
|
|
329
403
|
getPermissionMode() { return this.config.permissionMode; }
|
|
330
404
|
setPermissionMode(mode) { this.config.permissionMode = mode; }
|
|
405
|
+
getProvider() { return this.providerId; }
|
|
406
|
+
/**
|
|
407
|
+
* Whether this session can reach a Scalix gateway that answers /v1/me and
|
|
408
|
+
* serves the account's platform-tool catalog. True when inference runs on
|
|
409
|
+
* the gateway, or when BYOK inference is paired with a separate Scalix Cloud
|
|
410
|
+
* key. False means no endpoint to probe — never probe the BYOK base URL.
|
|
411
|
+
*/
|
|
412
|
+
supportsPlatformTools() { return this.cloudApiKey.length > 0; }
|
|
413
|
+
/** The Scalix Cloud endpoint platform tools talk to — the gateway, not the
|
|
414
|
+
* inference base URL, which under BYOK is a third-party host. */
|
|
415
|
+
getCloudBaseUrl() { return this.cloudBaseUrl; }
|
|
416
|
+
/** The key platform tools authenticate with — equal to the inference key on
|
|
417
|
+
* the 'scalix' provider, separate under BYOK, empty if unconfigured. */
|
|
418
|
+
getCloudApiKey() { return this.cloudApiKey; }
|
|
331
419
|
getThinking() { return this.config.thinkingMode; }
|
|
332
420
|
setThinking(mode) { this.config.thinkingMode = mode; }
|
|
333
421
|
async compact() {
|
|
@@ -345,6 +433,7 @@ export class Agent extends EventEmitter {
|
|
|
345
433
|
const result = await this.client.chatComplete({
|
|
346
434
|
model,
|
|
347
435
|
messages: apiMessages,
|
|
436
|
+
...this.maxTokensField(model),
|
|
348
437
|
stream: false,
|
|
349
438
|
}, this._abortController?.signal);
|
|
350
439
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
@@ -368,8 +457,68 @@ export class Agent extends EventEmitter {
|
|
|
368
457
|
return `Compaction failed: ${err.message}`;
|
|
369
458
|
}
|
|
370
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Point the context manager at the active model's real input budget. The
|
|
462
|
+
* manager starts on a generic 1M default, so until this runs every reader —
|
|
463
|
+
* /status, /context, the editor's context ring — quotes a window the model
|
|
464
|
+
* does not have. It used to run only inside the send loop, which meant a
|
|
465
|
+
* fresh session reported 1M until the first reply landed.
|
|
466
|
+
*
|
|
467
|
+
* Precedence: a registered spec (user-defined or fetched from the endpoint)
|
|
468
|
+
* is per-model truth and wins. Without one, getModelSpec() hands back a
|
|
469
|
+
* generic 200K/64K guess — and a guess must not overwrite a window the user
|
|
470
|
+
* typed into the setup wizard. It did, which is why a BYOK 8K local model was
|
|
471
|
+
* still being treated as a 134K one: nothing compressed until the endpoint
|
|
472
|
+
* itself started rejecting the oversized request. The untouched 1M default
|
|
473
|
+
* isn't a choice, so it stays out of the way of the guess.
|
|
474
|
+
*/
|
|
475
|
+
// Default to models[0] — the model a fresh send will use. modelIndex points
|
|
476
|
+
// one PAST the last pick (retry bookkeeping), so dereferencing it here made
|
|
477
|
+
// idle-time context reads use the NEXT chain entry's budget, not the
|
|
478
|
+
// active model's.
|
|
479
|
+
syncContextLimit(model = this.config.models[0]) {
|
|
480
|
+
const configured = this.configuredContextTokens;
|
|
481
|
+
const userChoseLimit = configured != null
|
|
482
|
+
&& configured > 0
|
|
483
|
+
&& configured !== CONTEXT_DEFAULTS.maxContextTokens;
|
|
484
|
+
if (userChoseLimit && !hasExplicitModelSpec(model)) {
|
|
485
|
+
this.contextManager.setMaxContextTokens(configured);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const spec = getModelSpec(model);
|
|
489
|
+
const inputBudget = spec.contextWindow - spec.maxOutputTokens;
|
|
490
|
+
if (inputBudget > 0)
|
|
491
|
+
this.contextManager.setMaxContextTokens(inputBudget);
|
|
492
|
+
}
|
|
371
493
|
getContextUsage() {
|
|
372
|
-
|
|
494
|
+
this.syncContextLimit();
|
|
495
|
+
const usage = this.contextManager.getTokenUsage(this.messages);
|
|
496
|
+
// messagesToApi() prepends the instructions at request time, so the system
|
|
497
|
+
// prompt is never in this.messages — but it is sent on every request and
|
|
498
|
+
// occupies the window like anything else. Counting it keeps /context,
|
|
499
|
+
// /status and the editor's context ring honest instead of quietly low.
|
|
500
|
+
const tokens = usage.tokens + estimateTokens(this.config.instructions || '');
|
|
501
|
+
return {
|
|
502
|
+
tokens,
|
|
503
|
+
maxTokens: usage.maxTokens,
|
|
504
|
+
percentage: Math.round((tokens / usage.maxTokens) * 100),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Per-role token split of the current context. estimateMessageTokens() is
|
|
509
|
+
* additive per message, so grouping by role and summing the groups gives
|
|
510
|
+
* exactly getContextUsage().tokens — these are measured, not apportioned.
|
|
511
|
+
*/
|
|
512
|
+
getContextBreakdown() {
|
|
513
|
+
const bucket = { system: [], user: [], assistant: [], tool: [] };
|
|
514
|
+
for (const m of this.messages)
|
|
515
|
+
bucket[m.role]?.push(m);
|
|
516
|
+
return {
|
|
517
|
+
system: estimateTokens(this.config.instructions || '') + estimateTokenCount(bucket.system),
|
|
518
|
+
user: estimateTokenCount(bucket.user),
|
|
519
|
+
assistant: estimateTokenCount(bucket.assistant),
|
|
520
|
+
tools: estimateTokenCount(bucket.tool),
|
|
521
|
+
};
|
|
373
522
|
}
|
|
374
523
|
async autoCompress(reqLog) {
|
|
375
524
|
if (!this.contextManager.shouldCompress(this.messages)) {
|
|
@@ -400,6 +549,7 @@ export class Agent extends EventEmitter {
|
|
|
400
549
|
{ role: 'system', content: 'You compress conversation history. Preserve every file path, function name, error message, code change, command, config value, and user instruction verbatim. Omit greetings and filler only. This output replaces the original — anything omitted is permanently lost.' },
|
|
401
550
|
{ role: 'user', content: prompt },
|
|
402
551
|
],
|
|
552
|
+
...this.maxTokensField(model),
|
|
403
553
|
stream: false,
|
|
404
554
|
}, this._abortController?.signal);
|
|
405
555
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
@@ -438,8 +588,8 @@ export class Agent extends EventEmitter {
|
|
|
438
588
|
* Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
|
|
439
589
|
* which transformToolsForApi would run through the Zod converter). */
|
|
440
590
|
makeCloudTool(ct) {
|
|
441
|
-
const baseUrl = this.
|
|
442
|
-
const apiKey = this.
|
|
591
|
+
const baseUrl = this.cloudBaseUrl;
|
|
592
|
+
const apiKey = this.cloudApiKey;
|
|
443
593
|
const projectId = this.cloudProjectId;
|
|
444
594
|
return {
|
|
445
595
|
source: 'scalix-cloud',
|
|
@@ -460,19 +610,41 @@ export class Agent extends EventEmitter {
|
|
|
460
610
|
* (/cloud refresh). On failure the previously registered cloud tools are
|
|
461
611
|
* removed and the agent keeps running with local tools only — the returned
|
|
462
612
|
* status carries the real error for a single honest notice; no retries.
|
|
613
|
+
*
|
|
614
|
+
* `identity`: pass a CloudIdentity to scope-filter the catalog without a
|
|
615
|
+
* second /v1/me round-trip (the TUI already fetched one); pass `null` when
|
|
616
|
+
* /v1/me was tried and failed so no doomed re-fetch happens; omit entirely
|
|
617
|
+
* (headless, IPC) and the identity is fetched best-effort here — on any
|
|
618
|
+
* /v1/me failure the catalog registers unfiltered, exactly as before.
|
|
463
619
|
*/
|
|
464
|
-
async refreshCloudTools() {
|
|
465
|
-
|
|
620
|
+
async refreshCloudTools(identity) {
|
|
621
|
+
// Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp), which
|
|
622
|
+
// is `cloudBaseUrl` — never the BYOK inference endpoint. No cloud key
|
|
623
|
+
// means nothing to fetch: on 'scalix' the user has no key at all, on BYOK
|
|
624
|
+
// they have not opted into pairing a Scalix Cloud key with their provider.
|
|
625
|
+
if (!this.cloudApiKey) {
|
|
466
626
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
467
627
|
this.cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
468
628
|
return { ...this.cloudStatus };
|
|
469
629
|
}
|
|
470
630
|
try {
|
|
471
|
-
const catalog = await fetchCloudTools(this.
|
|
631
|
+
const catalog = await fetchCloudTools(this.cloudBaseUrl, this.cloudApiKey, this.cloudProjectId);
|
|
632
|
+
let me = identity;
|
|
633
|
+
if (me === undefined) {
|
|
634
|
+
try {
|
|
635
|
+
me = await fetchMe(this.cloudBaseUrl, this.cloudApiKey);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
me = null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const visible = me
|
|
642
|
+
? filterCatalogByScopes(catalog.tools, me.permissions.scopes)
|
|
643
|
+
: catalog.tools;
|
|
472
644
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
473
645
|
const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
474
646
|
let registered = 0;
|
|
475
|
-
for (const ct of
|
|
647
|
+
for (const ct of visible) {
|
|
476
648
|
if (localNames.has(ct.name)) {
|
|
477
649
|
this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
|
|
478
650
|
continue;
|
|
@@ -485,14 +657,26 @@ export class Agent extends EventEmitter {
|
|
|
485
657
|
this.cloudStatus = {
|
|
486
658
|
state: 'connected',
|
|
487
659
|
toolCount: registered,
|
|
660
|
+
totalCount: catalog.tools.length,
|
|
488
661
|
platformAvailable: catalog.platformAvailable,
|
|
489
662
|
};
|
|
663
|
+
// The platform binds MCP calls to the KEY's project; a configured
|
|
664
|
+
// project id cannot redirect them. Say so instead of silently differing.
|
|
665
|
+
const keyProject = me?.identity.projectId;
|
|
666
|
+
if (keyProject && this.cloudProjectId && keyProject !== this.cloudProjectId) {
|
|
667
|
+
this.cloudStatus.note = `API key is bound to project ${keyProject}; configured project ${this.cloudProjectId} is ignored for platform tools (the key's binding wins).`;
|
|
668
|
+
}
|
|
490
669
|
}
|
|
491
670
|
catch (err) {
|
|
492
671
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
493
672
|
if (this.toolSelector)
|
|
494
673
|
this.toolSelector.index(this.config.tools);
|
|
495
|
-
this.cloudStatus = {
|
|
674
|
+
this.cloudStatus = {
|
|
675
|
+
state: 'unavailable',
|
|
676
|
+
toolCount: 0,
|
|
677
|
+
error: err?.message || String(err),
|
|
678
|
+
remedy: cloudKeyRemedy(err),
|
|
679
|
+
};
|
|
496
680
|
this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
|
|
497
681
|
}
|
|
498
682
|
return { ...this.cloudStatus };
|
|
@@ -510,12 +694,39 @@ export class Agent extends EventEmitter {
|
|
|
510
694
|
if (!this.config.models.includes(model))
|
|
511
695
|
this.config.models.push(model);
|
|
512
696
|
}
|
|
513
|
-
setModel(model) {
|
|
697
|
+
setModel(model) {
|
|
698
|
+
// Mutate IN PLACE, never reassign: setAgentToolConfig captured this array
|
|
699
|
+
// by reference at construction, so a reassignment left sub-agents on the
|
|
700
|
+
// pre-switch model list forever. And keep the failover chain — replacing
|
|
701
|
+
// the whole list with [model] permanently destroyed a configured
|
|
702
|
+
// multi-model chain; the switched-to model just moves to the front.
|
|
703
|
+
const rest = this.config.models.filter((m) => m !== model);
|
|
704
|
+
this.config.models.splice(0, this.config.models.length, model, ...rest);
|
|
705
|
+
this.modelIndex = 0;
|
|
706
|
+
// A deliberate switch is a vote of confidence: clear this model's breaker
|
|
707
|
+
// so the next request actually tries it, and resize the context budget
|
|
708
|
+
// now instead of at the next lazy resync.
|
|
709
|
+
this.modelFailures.delete(model);
|
|
710
|
+
this.syncContextLimit(model);
|
|
711
|
+
}
|
|
514
712
|
getModels() { return [...this.config.models]; }
|
|
713
|
+
/** Live-apply a limit. Returns true when the running agent honors the new
|
|
714
|
+
* value immediately, false when it only takes effect on the next launch —
|
|
715
|
+
* callers used to report "updated" unconditionally, which was a lie for
|
|
716
|
+
* every key that lives outside AgentConfig (web/git/search timeouts, …). */
|
|
515
717
|
updateLimit(key, value) {
|
|
718
|
+
// Context keys live nested under config.contextManager and on the live
|
|
719
|
+
// ContextManager, not as top-level AgentConfig fields.
|
|
720
|
+
if (key === 'maxContextTokens') {
|
|
721
|
+
this.configuredContextTokens = value;
|
|
722
|
+
this.contextManager.setMaxContextTokens(value);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
516
725
|
if (key in this.config) {
|
|
517
726
|
this.config[key] = value;
|
|
727
|
+
return true;
|
|
518
728
|
}
|
|
729
|
+
return false;
|
|
519
730
|
}
|
|
520
731
|
getReplay() { return this.replay; }
|
|
521
732
|
getChangeTracker() { return this.changeTracker; }
|
|
@@ -719,7 +930,7 @@ export class Agent extends EventEmitter {
|
|
|
719
930
|
reqLog.info('User message received', { length: content.length });
|
|
720
931
|
this.registerInRegistry();
|
|
721
932
|
try {
|
|
722
|
-
const initialModel = this.pickModel();
|
|
933
|
+
const initialModel = this.pickModel(true);
|
|
723
934
|
reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
|
|
724
935
|
let result = await this.loopStreamingTurns(initialModel, reqLog);
|
|
725
936
|
if (this.outputFormat !== 'text') {
|
|
@@ -731,13 +942,24 @@ export class Agent extends EventEmitter {
|
|
|
731
942
|
catch (err) {
|
|
732
943
|
this.deregisterFromRegistry('failed');
|
|
733
944
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
945
|
+
const status = err.status ?? err.statusCode;
|
|
946
|
+
// 401 is an onboarding problem, not a request problem — point at the fix.
|
|
947
|
+
// Which fix depends on whose key was rejected: on BYOK the request went to
|
|
948
|
+
// the user's own endpoint, so sending them to the Scalix console to mint a
|
|
949
|
+
// platform key is a dead end that doesn't touch the credential that failed.
|
|
950
|
+
if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
|
|
951
|
+
error.message += this.capabilities.platformTools
|
|
952
|
+
? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys and update it via /setup.'
|
|
953
|
+
: ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
|
|
954
|
+
}
|
|
734
955
|
error.metadata = {
|
|
735
956
|
model: this.config.models[this.modelIndex % this.config.models.length],
|
|
736
|
-
status
|
|
957
|
+
status,
|
|
737
958
|
error: err.error,
|
|
738
959
|
requestId,
|
|
739
960
|
};
|
|
740
961
|
reqLog.error('Agent send failed', { error: error.message });
|
|
962
|
+
error[REPORTED_ERROR] = true;
|
|
741
963
|
this.emit('error', error);
|
|
742
964
|
throw error;
|
|
743
965
|
}
|
|
@@ -750,6 +972,7 @@ export class Agent extends EventEmitter {
|
|
|
750
972
|
let turnCount = 0;
|
|
751
973
|
let retryAttempt = 0;
|
|
752
974
|
let emptyTurnCount = 0;
|
|
975
|
+
const MAX_EMPTY_TURNS = 5;
|
|
753
976
|
let disableTools = false;
|
|
754
977
|
let forceNonStreaming = false;
|
|
755
978
|
while (turnCount < this.config.maxSteps) {
|
|
@@ -764,13 +987,16 @@ export class Agent extends EventEmitter {
|
|
|
764
987
|
const toolCalls = new Map();
|
|
765
988
|
try {
|
|
766
989
|
// Sync context limit to the current model's actual context window
|
|
767
|
-
|
|
768
|
-
const inputBudget = modelSpec.contextWindow - modelSpec.maxOutputTokens;
|
|
769
|
-
if (inputBudget > 0) {
|
|
770
|
-
this.contextManager.setMaxContextTokens(inputBudget);
|
|
771
|
-
}
|
|
990
|
+
this.syncContextLimit(currentModel);
|
|
772
991
|
await this.autoCompress(reqLog);
|
|
773
|
-
|
|
992
|
+
// getContextUsage(), not contextManager.getTokenUsage(): the former adds
|
|
993
|
+
// the system prompt, and a listener that renders both this event and a
|
|
994
|
+
// getContextInfo() response must not see two different totals.
|
|
995
|
+
this.emit('context:usage', {
|
|
996
|
+
...this.getContextUsage(),
|
|
997
|
+
messageCount: this.messages.length,
|
|
998
|
+
breakdown: this.getContextBreakdown(),
|
|
999
|
+
});
|
|
774
1000
|
let toolsForRequest = this.config.tools;
|
|
775
1001
|
if (this.toolSelector) {
|
|
776
1002
|
const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
|
|
@@ -803,17 +1029,42 @@ export class Agent extends EventEmitter {
|
|
|
803
1029
|
}
|
|
804
1030
|
}
|
|
805
1031
|
}
|
|
1032
|
+
// Scalix steering + live cloud-tools awareness, assembled here — the
|
|
1033
|
+
// one point every frontend (TUI, headless, IPC) funnels through —
|
|
1034
|
+
// and rebuilt per request so a /cloud refresh shows up on the next
|
|
1035
|
+
// turn. Coder is a generic tool: the product steering only appears
|
|
1036
|
+
// while a Scalix Cloud connection is live; on the Scalix provider
|
|
1037
|
+
// without one a single neutral pointer; on pure BYOK nothing at all.
|
|
1038
|
+
if (this.cloudStatus.state === 'connected') {
|
|
1039
|
+
if (!SCALIX_STEERING_ALWAYS_ON) {
|
|
1040
|
+
effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
|
|
1041
|
+
}
|
|
1042
|
+
const liveCloudTools = this.getCloudTools();
|
|
1043
|
+
if (liveCloudTools.length > 0) {
|
|
1044
|
+
effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
else if (!SCALIX_STEERING_ALWAYS_ON && this.providerId === 'scalix') {
|
|
1048
|
+
effectiveInstructions += '\n\n' + SCALIX_NEUTRAL_POINTER;
|
|
1049
|
+
}
|
|
806
1050
|
const vision = modelSupportsVision(currentModel);
|
|
807
1051
|
const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
|
|
808
1052
|
const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
|
|
809
1053
|
const hasToolMessages = this.messages.some(m => m.role === 'tool' || (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0));
|
|
810
|
-
|
|
1054
|
+
// An endpoint that does not carry tool calls over SSE would answer an
|
|
1055
|
+
// agentic turn in prose and lose the calls, so those turns go
|
|
1056
|
+
// non-streaming there and the user watches a spinner instead of output.
|
|
1057
|
+
// Text-only turns stream either way.
|
|
1058
|
+
const useStreaming = !forceNonStreaming && (await this.streamsToolCalls()
|
|
1059
|
+
? true
|
|
1060
|
+
: (!apiTools || apiTools.length === 0) && !hasToolMessages);
|
|
811
1061
|
if (useStreaming) {
|
|
812
1062
|
const stream = await this.client.chatComplete({
|
|
813
1063
|
model: currentModel,
|
|
814
1064
|
messages: apiMessages,
|
|
815
1065
|
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
816
1066
|
temperature: 0.7,
|
|
1067
|
+
...this.maxTokensField(currentModel),
|
|
817
1068
|
stream: true,
|
|
818
1069
|
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
819
1070
|
}, this._abortController.signal);
|
|
@@ -869,6 +1120,7 @@ export class Agent extends EventEmitter {
|
|
|
869
1120
|
messages: apiMessages,
|
|
870
1121
|
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
871
1122
|
temperature: 0.7,
|
|
1123
|
+
...this.maxTokensField(currentModel),
|
|
872
1124
|
stream: false,
|
|
873
1125
|
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
874
1126
|
}, this._abortController.signal);
|
|
@@ -928,10 +1180,21 @@ export class Agent extends EventEmitter {
|
|
|
928
1180
|
disableTools = true;
|
|
929
1181
|
reqLog.warn('Disabling tools due to repeated empty turns — model may not support function calling');
|
|
930
1182
|
}
|
|
931
|
-
if (emptyTurnCount >=
|
|
1183
|
+
if (emptyTurnCount >= MAX_EMPTY_TURNS) {
|
|
932
1184
|
reqLog.error('Too many empty turns, aborting');
|
|
933
1185
|
return '[no response]';
|
|
934
1186
|
}
|
|
1187
|
+
// Re-asking used to be entirely silent: from the TUI this is several
|
|
1188
|
+
// seconds of spinner followed, on the last attempt, by nothing at
|
|
1189
|
+
// all. Reuse the retry event so the transcript says what happened.
|
|
1190
|
+
// Emitted after the bail check, so the count only ever describes
|
|
1191
|
+
// retries that actually happen.
|
|
1192
|
+
this.emit('retry', {
|
|
1193
|
+
attempt: emptyTurnCount,
|
|
1194
|
+
maxAttempts: MAX_EMPTY_TURNS - 1,
|
|
1195
|
+
delayMs: 0,
|
|
1196
|
+
reason: 'Model returned an empty response',
|
|
1197
|
+
});
|
|
935
1198
|
continue;
|
|
936
1199
|
}
|
|
937
1200
|
emptyTurnCount = 0;
|
|
@@ -1063,6 +1326,26 @@ export class Agent extends EventEmitter {
|
|
|
1063
1326
|
const args = tool?.source === 'scalix-cloud'
|
|
1064
1327
|
? parsedArgs
|
|
1065
1328
|
: normalizeArgs(parsedArgs);
|
|
1329
|
+
// Validate against the very schema the model was shown. Nothing
|
|
1330
|
+
// checked this before, so a hallucinated parameter name reached
|
|
1331
|
+
// the tool body and came back as a Node internals message —
|
|
1332
|
+
// read_file called with {path} instead of {filePath} answered
|
|
1333
|
+
// 'The "paths[1]" argument must be of type string. Received
|
|
1334
|
+
// undefined', which tells the model nothing it can act on. Args
|
|
1335
|
+
// are passed through unchanged on success rather than swapped
|
|
1336
|
+
// for the parsed copy, since z.object() strips unknown keys and
|
|
1337
|
+
// some tools read fields their schema doesn't declare.
|
|
1338
|
+
const schema = tool?.function?.inputSchema ?? tool?.inputSchema;
|
|
1339
|
+
if (schema && typeof schema.safeParse === 'function') {
|
|
1340
|
+
const check = schema.safeParse(args);
|
|
1341
|
+
if (!check.success) {
|
|
1342
|
+
const issues = (check.error?.issues ?? [])
|
|
1343
|
+
.slice(0, 4)
|
|
1344
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
1345
|
+
.join('; ');
|
|
1346
|
+
throw new Error(`Invalid arguments for ${call.name} — ${issues || 'schema validation failed'}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1066
1349
|
reqLog.debug('Executing tool', { tool: call.name });
|
|
1067
1350
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
1068
1351
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
@@ -1236,11 +1519,21 @@ export class Agent extends EventEmitter {
|
|
|
1236
1519
|
if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
|
|
1237
1520
|
retryAttempt++;
|
|
1238
1521
|
forceNonStreaming = false;
|
|
1239
|
-
|
|
1522
|
+
// Honor the server's Retry-After on 429/503 (capped at 60s) instead
|
|
1523
|
+
// of a blind exponential guess; fall back to backoff otherwise.
|
|
1524
|
+
const serverWait = err instanceof ApiError ? err.retryAfterMs : undefined;
|
|
1525
|
+
const delay = serverWait !== undefined ? Math.min(serverWait, 60_000) : backoffDelay(retryAttempt);
|
|
1240
1526
|
reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
|
|
1241
1527
|
model: currentModel,
|
|
1242
1528
|
error: err.message,
|
|
1243
1529
|
});
|
|
1530
|
+
this.emit('retry', {
|
|
1531
|
+
attempt: retryAttempt,
|
|
1532
|
+
maxAttempts: this.config.maxRetries,
|
|
1533
|
+
delayMs: Math.round(delay),
|
|
1534
|
+
status: err instanceof ApiError ? err.status : undefined,
|
|
1535
|
+
reason: err.message,
|
|
1536
|
+
});
|
|
1244
1537
|
await sleep(delay);
|
|
1245
1538
|
currentModel = this.pickModel();
|
|
1246
1539
|
continue;
|