scalix 1.5.0 → 1.5.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/.env.example +5 -3
- package/CHANGELOG.md +24 -0
- package/LICENSE +0 -1
- package/README.md +47 -2
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +358 -42
- package/dist/api-client.js +12 -4
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +435 -94
- package/dist/cloud.js +95 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +121 -19
- package/dist/context-builder.js +9 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +44 -13
- package/dist/ipc-server.js +362 -99
- package/dist/keychain.js +34 -9
- package/dist/mcp.js +470 -28
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/sandbox.js +115 -27
- package/dist/sessions.js +12 -0
- package/dist/settings.js +33 -2
- package/dist/slash-commands.js +6 -0
- package/dist/sub-agent.js +3 -2
- package/dist/tools-browser.js +87 -16
- package/dist/tools-extra.js +15 -3
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +67 -6
- package/dist/tools.js +15 -14
- package/dist/workspace-trust.js +7 -1
- package/package.json +3 -1
package/dist/agent.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { ApiError } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { getToolPermissionWithArgs } from './sandbox.js';
|
|
4
|
+
import { getToolPermissionWithArgs, getMaxImageSize } 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, CONTEXT_DEFAULTS, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.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_DEPLOY_PLAYBOOK_SECTION, SCALIX_PLATFORM_PLAYBOOK_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
|
|
7
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';
|
|
11
11
|
import { AgentReplay } from './agent-replay.js';
|
|
12
12
|
import { loadHooks, runHooks } from './hooks.js';
|
|
13
|
+
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
13
14
|
import { setAgentToolConfig } from './tools-advanced.js';
|
|
14
15
|
import { AgentRegistry, generateAgentId } from './agent-registry.js';
|
|
16
|
+
import { getPreset } from './agent-presets.js';
|
|
15
17
|
import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
|
|
16
18
|
import { ChangeTracker } from './change-tracker.js';
|
|
17
19
|
import { detectPromptInjection, formatInjectionWarning } from './injection-detector.js';
|
|
@@ -19,8 +21,28 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
|
|
|
19
21
|
import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
|
|
20
22
|
import fs from 'node:fs/promises';
|
|
21
23
|
import path from 'node:path';
|
|
22
|
-
import { fetchCloudTools, callCloudTool } from './cloud.js';
|
|
24
|
+
import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
|
|
23
25
|
import { createChatClient, capabilitiesFor } from './providers/index.js';
|
|
26
|
+
import { McpHub } from './mcp.js';
|
|
27
|
+
import { BoardTurnRefresher } from './board-context.js';
|
|
28
|
+
const SUPPORTED_PASTED_IMAGE_MIMES = new Set([
|
|
29
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml',
|
|
30
|
+
]);
|
|
31
|
+
export function validatePastedImages(images) {
|
|
32
|
+
const maxSize = getMaxImageSize();
|
|
33
|
+
for (const image of images) {
|
|
34
|
+
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(image);
|
|
35
|
+
if (!match || !SUPPORTED_PASTED_IMAGE_MIMES.has(match[1].toLowerCase())) {
|
|
36
|
+
throw new Error('Unsupported pasted image. Use PNG, JPEG, GIF, WebP, BMP, or SVG.');
|
|
37
|
+
}
|
|
38
|
+
const payload = match[2].replace(/\s/g, '');
|
|
39
|
+
const bytes = Math.floor(payload.length * 3 / 4)
|
|
40
|
+
- (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0);
|
|
41
|
+
if (bytes > maxSize) {
|
|
42
|
+
throw new Error(`Pasted image is too large (${Math.ceil(bytes / 1024 / 1024)}MB). Maximum is ${Math.floor(maxSize / 1024 / 1024)}MB.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
24
46
|
/**
|
|
25
47
|
* Marks an error that Agent has already announced through the 'error' event.
|
|
26
48
|
* `send()` emits and then rethrows, so a caller that also prints from its catch
|
|
@@ -30,11 +52,10 @@ import { createChatClient, capabilitiesFor } from './providers/index.js';
|
|
|
30
52
|
export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
|
|
31
53
|
function transformToolsForApi(tools) {
|
|
32
54
|
return tools
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
.filter((t) => !('type' in t && t.type === 'mcp'))
|
|
55
|
+
// Everything here is a function tool. Remotely-backed ones — Scalix Cloud
|
|
56
|
+
// platform tools and MCP tool proxies — carry a ready-made JSON Schema in
|
|
57
|
+
// `parameters` that passes through as-is; local tools carry a Zod
|
|
58
|
+
// `inputSchema` that gets converted below.
|
|
38
59
|
.map((t) => {
|
|
39
60
|
const local = t;
|
|
40
61
|
const fn = local.function ?? local;
|
|
@@ -151,7 +172,10 @@ export class Agent extends EventEmitter {
|
|
|
151
172
|
_aborted = false;
|
|
152
173
|
_thinkingActive = false;
|
|
153
174
|
replay;
|
|
175
|
+
boardRefresher;
|
|
176
|
+
boardPrimed;
|
|
154
177
|
hooks = [];
|
|
178
|
+
hooksSkipped = 0;
|
|
155
179
|
instructionLoader;
|
|
156
180
|
_abortController = null;
|
|
157
181
|
modelFailures = new Map();
|
|
@@ -175,8 +199,11 @@ export class Agent extends EventEmitter {
|
|
|
175
199
|
outputFormatInstructionIndex = null;
|
|
176
200
|
cloudProjectId;
|
|
177
201
|
cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
202
|
+
mcpHub = null;
|
|
178
203
|
providerId;
|
|
179
204
|
capabilities;
|
|
205
|
+
cloudApiKey;
|
|
206
|
+
cloudBaseUrl;
|
|
180
207
|
streamingToolCallsResolved;
|
|
181
208
|
constructor(config) {
|
|
182
209
|
super();
|
|
@@ -189,6 +216,14 @@ export class Agent extends EventEmitter {
|
|
|
189
216
|
throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
|
|
190
217
|
}
|
|
191
218
|
this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
|
|
219
|
+
// Cloud tools follow their own credential pair. On the Scalix gateway the
|
|
220
|
+
// inference creds double as the cloud creds; on BYOK `config.baseUrl` is a
|
|
221
|
+
// third-party host with no /v1/me, so the cloud URL falls back to the
|
|
222
|
+
// Scalix default rather than inheriting it.
|
|
223
|
+
this.cloudApiKey = config.cloudApiKey?.trim()
|
|
224
|
+
|| (this.capabilities.platformTools ? config.apiKey : '');
|
|
225
|
+
this.cloudBaseUrl = config.cloudBaseUrl?.trim()
|
|
226
|
+
|| (this.capabilities.platformTools ? (config.baseUrl || DEFAULT_BASE_URL) : DEFAULT_BASE_URL);
|
|
192
227
|
this.log = logger.child();
|
|
193
228
|
const resolvedModels = Array.isArray(config.model)
|
|
194
229
|
? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
|
|
@@ -223,8 +258,28 @@ export class Agent extends EventEmitter {
|
|
|
223
258
|
this.toolSelector = new ToolSelector();
|
|
224
259
|
this.toolSelector.index(this.config.tools);
|
|
225
260
|
}
|
|
261
|
+
// Crew board freshness (board-context.ts): baseline taken here, right
|
|
262
|
+
// after the system prompt embedded the board, so the first turnNote()
|
|
263
|
+
// only fires on real change. Fire-and-forget; send() awaits it.
|
|
264
|
+
this.boardRefresher = new BoardTurnRefresher(this.config.workspaceRoot ?? process.cwd());
|
|
265
|
+
this.boardPrimed = this.boardRefresher.prime().catch(() => { });
|
|
226
266
|
this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
|
|
227
|
-
|
|
267
|
+
// Hooks are shell commands read out of the project's own .scalix/hooks.json
|
|
268
|
+
// — exec surface, exactly like plugins and scoped rules, and named as such
|
|
269
|
+
// in workspace-trust.ts's own docstring. The gate existed; this call site
|
|
270
|
+
// just never asked. Parsing the file while untrusted is still fine and is
|
|
271
|
+
// what lets us say "3 hooks were skipped" instead of going quietly inert.
|
|
272
|
+
const projectHooks = loadHooks();
|
|
273
|
+
if (isWorkspaceTrusted()) {
|
|
274
|
+
this.hooks = projectHooks;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
this.hooks = [];
|
|
278
|
+
this.hooksSkipped = projectHooks.length;
|
|
279
|
+
if (projectHooks.length > 0) {
|
|
280
|
+
this.log.warn('Project hooks skipped — untrusted workspace', { count: projectHooks.length });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
228
283
|
this.instructionLoader = config.instructionLoader;
|
|
229
284
|
if (config.autoVerify === true) {
|
|
230
285
|
this.verifyConfig = { ...VERIFY_DEFAULTS };
|
|
@@ -268,7 +323,16 @@ export class Agent extends EventEmitter {
|
|
|
268
323
|
this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
|
|
269
324
|
}
|
|
270
325
|
}
|
|
271
|
-
|
|
326
|
+
/**
|
|
327
|
+
* `fresh` — a NEW user turn: always start from the front of the list (the
|
|
328
|
+
* user's chosen model). The idx+1 bookkeeping below exists so RETRIES
|
|
329
|
+
* advance to the next model in the chain; without the fresh reset it also
|
|
330
|
+
* rotated the STARTING model, so with a failover chain configured,
|
|
331
|
+
* consecutive sends silently alternated models — a switch never "stuck".
|
|
332
|
+
*/
|
|
333
|
+
pickModel(fresh = false) {
|
|
334
|
+
if (fresh)
|
|
335
|
+
this.modelIndex = 0;
|
|
272
336
|
const startIdx = this.modelIndex;
|
|
273
337
|
for (let i = 0; i < this.config.models.length; i++) {
|
|
274
338
|
const idx = (startIdx + i) % this.config.models.length;
|
|
@@ -335,6 +399,16 @@ export class Agent extends EventEmitter {
|
|
|
335
399
|
message.includes('stream error') ||
|
|
336
400
|
message.includes('try again'));
|
|
337
401
|
}
|
|
402
|
+
setPreset(presetId) {
|
|
403
|
+
const preset = getPreset(presetId);
|
|
404
|
+
if (!preset)
|
|
405
|
+
throw new Error(`Unknown preset: ${presetId}`);
|
|
406
|
+
this.config.permissionMode = preset.permissionMode;
|
|
407
|
+
// We append the prompt to the base instructions
|
|
408
|
+
if (preset.systemPromptAdditions && !this.config.instructions.includes(preset.systemPromptAdditions)) {
|
|
409
|
+
this.config.instructions += '\n\n' + preset.systemPromptAdditions;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
338
412
|
isModelCircuitOpen(model) {
|
|
339
413
|
const state = this.modelFailures.get(model);
|
|
340
414
|
if (!state)
|
|
@@ -385,12 +459,18 @@ export class Agent extends EventEmitter {
|
|
|
385
459
|
setPermissionMode(mode) { this.config.permissionMode = mode; }
|
|
386
460
|
getProvider() { return this.providerId; }
|
|
387
461
|
/**
|
|
388
|
-
* Whether this session
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
462
|
+
* Whether this session can reach a Scalix gateway that answers /v1/me and
|
|
463
|
+
* serves the account's platform-tool catalog. True when inference runs on
|
|
464
|
+
* the gateway, or when BYOK inference is paired with a separate Scalix Cloud
|
|
465
|
+
* key. False means no endpoint to probe — never probe the BYOK base URL.
|
|
392
466
|
*/
|
|
393
|
-
supportsPlatformTools() { return this.
|
|
467
|
+
supportsPlatformTools() { return this.cloudApiKey.length > 0; }
|
|
468
|
+
/** The Scalix Cloud endpoint platform tools talk to — the gateway, not the
|
|
469
|
+
* inference base URL, which under BYOK is a third-party host. */
|
|
470
|
+
getCloudBaseUrl() { return this.cloudBaseUrl; }
|
|
471
|
+
/** The key platform tools authenticate with — equal to the inference key on
|
|
472
|
+
* the 'scalix' provider, separate under BYOK, empty if unconfigured. */
|
|
473
|
+
getCloudApiKey() { return this.cloudApiKey; }
|
|
394
474
|
getThinking() { return this.config.thinkingMode; }
|
|
395
475
|
setThinking(mode) { this.config.thinkingMode = mode; }
|
|
396
476
|
async compact() {
|
|
@@ -447,7 +527,11 @@ export class Agent extends EventEmitter {
|
|
|
447
527
|
* itself started rejecting the oversized request. The untouched 1M default
|
|
448
528
|
* isn't a choice, so it stays out of the way of the guess.
|
|
449
529
|
*/
|
|
450
|
-
|
|
530
|
+
// Default to models[0] — the model a fresh send will use. modelIndex points
|
|
531
|
+
// one PAST the last pick (retry bookkeeping), so dereferencing it here made
|
|
532
|
+
// idle-time context reads use the NEXT chain entry's budget, not the
|
|
533
|
+
// active model's.
|
|
534
|
+
syncContextLimit(model = this.config.models[0]) {
|
|
451
535
|
const configured = this.configuredContextTokens;
|
|
452
536
|
const userChoseLimit = configured != null
|
|
453
537
|
&& configured > 0
|
|
@@ -551,6 +635,64 @@ export class Agent extends EventEmitter {
|
|
|
551
635
|
if (this.toolSelector)
|
|
552
636
|
this.toolSelector.index(this.config.tools);
|
|
553
637
|
}
|
|
638
|
+
// ─── MCP servers ───
|
|
639
|
+
isMcpTool(t) {
|
|
640
|
+
return 'source' in t && t.source === 'mcp';
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Take ownership of a connected hub and register its tools. The entrypoint
|
|
644
|
+
* connects (so a slow server delays startup, not the first message) and hands
|
|
645
|
+
* the hub over; the agent needs it so `add_mcp_server` can connect at runtime
|
|
646
|
+
* and so `close()` can shut the child processes down.
|
|
647
|
+
*/
|
|
648
|
+
attachMcpHub(hub) {
|
|
649
|
+
this.mcpHub = hub;
|
|
650
|
+
this.syncMcpTools();
|
|
651
|
+
}
|
|
652
|
+
/** Replace the registered MCP proxies with whatever the hub currently holds. */
|
|
653
|
+
syncMcpTools() {
|
|
654
|
+
this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
|
|
655
|
+
if (this.mcpHub) {
|
|
656
|
+
const taken = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
657
|
+
for (const t of this.mcpHub.tools()) {
|
|
658
|
+
// A namespaced mcp__ name colliding with a local tool means two servers
|
|
659
|
+
// share a label; keep the first and say so rather than shadowing.
|
|
660
|
+
if (taken.has(t.name)) {
|
|
661
|
+
this.log.warn('MCP tool name already taken — skipping', { tool: t.name, server: t.serverLabel });
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
taken.add(t.name);
|
|
665
|
+
this.config.tools.push(t);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (this.toolSelector)
|
|
669
|
+
this.toolSelector.index(this.config.tools);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Connect one server and register its tools immediately. Creates the hub if
|
|
673
|
+
* the entrypoint never attached one, so a runtime `add_mcp_server` works even
|
|
674
|
+
* when no config file existed at startup.
|
|
675
|
+
*/
|
|
676
|
+
async connectMcpServer(config) {
|
|
677
|
+
if (!this.mcpHub)
|
|
678
|
+
this.mcpHub = new McpHub();
|
|
679
|
+
const [status] = await this.mcpHub.connect([config]);
|
|
680
|
+
this.syncMcpTools();
|
|
681
|
+
return status;
|
|
682
|
+
}
|
|
683
|
+
getMcpStatus() {
|
|
684
|
+
return this.mcpHub?.status() ?? [];
|
|
685
|
+
}
|
|
686
|
+
getMcpTools() {
|
|
687
|
+
return this.config.tools.filter((t) => this.isMcpTool(t));
|
|
688
|
+
}
|
|
689
|
+
/** Disconnect every MCP server. Call on shutdown — stdio servers are children. */
|
|
690
|
+
async closeMcp() {
|
|
691
|
+
const hub = this.mcpHub;
|
|
692
|
+
this.mcpHub = null;
|
|
693
|
+
this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
|
|
694
|
+
await hub?.close();
|
|
695
|
+
}
|
|
554
696
|
// ─── Scalix Cloud platform tools ───
|
|
555
697
|
isCloudTool(t) {
|
|
556
698
|
return 'source' in t && t.source === 'scalix-cloud';
|
|
@@ -559,8 +701,8 @@ export class Agent extends EventEmitter {
|
|
|
559
701
|
* Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
|
|
560
702
|
* which transformToolsForApi would run through the Zod converter). */
|
|
561
703
|
makeCloudTool(ct) {
|
|
562
|
-
const baseUrl = this.
|
|
563
|
-
const apiKey = this.
|
|
704
|
+
const baseUrl = this.cloudBaseUrl;
|
|
705
|
+
const apiKey = this.cloudApiKey;
|
|
564
706
|
const projectId = this.cloudProjectId;
|
|
565
707
|
return {
|
|
566
708
|
source: 'scalix-cloud',
|
|
@@ -581,26 +723,41 @@ export class Agent extends EventEmitter {
|
|
|
581
723
|
* (/cloud refresh). On failure the previously registered cloud tools are
|
|
582
724
|
* removed and the agent keeps running with local tools only — the returned
|
|
583
725
|
* status carries the real error for a single honest notice; no retries.
|
|
726
|
+
*
|
|
727
|
+
* `identity`: pass a CloudIdentity to scope-filter the catalog without a
|
|
728
|
+
* second /v1/me round-trip (the TUI already fetched one); pass `null` when
|
|
729
|
+
* /v1/me was tried and failed so no doomed re-fetch happens; omit entirely
|
|
730
|
+
* (headless, IPC) and the identity is fetched best-effort here — on any
|
|
731
|
+
* /v1/me failure the catalog registers unfiltered, exactly as before.
|
|
584
732
|
*/
|
|
585
|
-
async refreshCloudTools() {
|
|
586
|
-
// Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp)
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
return { ...this.cloudStatus };
|
|
592
|
-
}
|
|
593
|
-
if (!this.config.apiKey) {
|
|
733
|
+
async refreshCloudTools(identity) {
|
|
734
|
+
// Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp), which
|
|
735
|
+
// is `cloudBaseUrl` — never the BYOK inference endpoint. No cloud key
|
|
736
|
+
// means nothing to fetch: on 'scalix' the user has no key at all, on BYOK
|
|
737
|
+
// they have not opted into pairing a Scalix Cloud key with their provider.
|
|
738
|
+
if (!this.cloudApiKey) {
|
|
594
739
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
595
740
|
this.cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
596
741
|
return { ...this.cloudStatus };
|
|
597
742
|
}
|
|
598
743
|
try {
|
|
599
|
-
const catalog = await fetchCloudTools(this.
|
|
744
|
+
const catalog = await fetchCloudTools(this.cloudBaseUrl, this.cloudApiKey, this.cloudProjectId);
|
|
745
|
+
let me = identity;
|
|
746
|
+
if (me === undefined) {
|
|
747
|
+
try {
|
|
748
|
+
me = await fetchMe(this.cloudBaseUrl, this.cloudApiKey);
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
me = null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const visible = me
|
|
755
|
+
? filterCatalogByScopes(catalog.tools, me.permissions.scopes)
|
|
756
|
+
: catalog.tools;
|
|
600
757
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
601
758
|
const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
602
759
|
let registered = 0;
|
|
603
|
-
for (const ct of
|
|
760
|
+
for (const ct of visible) {
|
|
604
761
|
if (localNames.has(ct.name)) {
|
|
605
762
|
this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
|
|
606
763
|
continue;
|
|
@@ -613,14 +770,26 @@ export class Agent extends EventEmitter {
|
|
|
613
770
|
this.cloudStatus = {
|
|
614
771
|
state: 'connected',
|
|
615
772
|
toolCount: registered,
|
|
773
|
+
totalCount: catalog.tools.length,
|
|
616
774
|
platformAvailable: catalog.platformAvailable,
|
|
617
775
|
};
|
|
776
|
+
// The platform binds MCP calls to the KEY's project; a configured
|
|
777
|
+
// project id cannot redirect them. Say so instead of silently differing.
|
|
778
|
+
const keyProject = me?.identity.projectId;
|
|
779
|
+
if (keyProject && this.cloudProjectId && keyProject !== this.cloudProjectId) {
|
|
780
|
+
this.cloudStatus.note = `API key is bound to project ${keyProject}; configured project ${this.cloudProjectId} is ignored for platform tools (the key's binding wins).`;
|
|
781
|
+
}
|
|
618
782
|
}
|
|
619
783
|
catch (err) {
|
|
620
784
|
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
621
785
|
if (this.toolSelector)
|
|
622
786
|
this.toolSelector.index(this.config.tools);
|
|
623
|
-
this.cloudStatus = {
|
|
787
|
+
this.cloudStatus = {
|
|
788
|
+
state: 'unavailable',
|
|
789
|
+
toolCount: 0,
|
|
790
|
+
error: err?.message || String(err),
|
|
791
|
+
remedy: cloudKeyRemedy(err),
|
|
792
|
+
};
|
|
624
793
|
this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
|
|
625
794
|
}
|
|
626
795
|
return { ...this.cloudStatus };
|
|
@@ -638,16 +807,57 @@ export class Agent extends EventEmitter {
|
|
|
638
807
|
if (!this.config.models.includes(model))
|
|
639
808
|
this.config.models.push(model);
|
|
640
809
|
}
|
|
641
|
-
setModel(model) {
|
|
810
|
+
setModel(model) {
|
|
811
|
+
// Mutate IN PLACE, never reassign: setAgentToolConfig captured this array
|
|
812
|
+
// by reference at construction, so a reassignment left sub-agents on the
|
|
813
|
+
// pre-switch model list forever. And keep the failover chain — replacing
|
|
814
|
+
// the whole list with [model] permanently destroyed a configured
|
|
815
|
+
// multi-model chain; the switched-to model just moves to the front.
|
|
816
|
+
const rest = this.config.models.filter((m) => m !== model);
|
|
817
|
+
this.config.models.splice(0, this.config.models.length, model, ...rest);
|
|
818
|
+
this.modelIndex = 0;
|
|
819
|
+
// A deliberate switch is a vote of confidence: clear this model's breaker
|
|
820
|
+
// so the next request actually tries it, and resize the context budget
|
|
821
|
+
// now instead of at the next lazy resync.
|
|
822
|
+
this.modelFailures.delete(model);
|
|
823
|
+
this.syncContextLimit(model);
|
|
824
|
+
}
|
|
642
825
|
getModels() { return [...this.config.models]; }
|
|
826
|
+
/** Live-apply a limit. Returns true when the running agent honors the new
|
|
827
|
+
* value immediately, false when it only takes effect on the next launch —
|
|
828
|
+
* callers used to report "updated" unconditionally, which was a lie for
|
|
829
|
+
* every key that lives outside AgentConfig (web/git/search timeouts, …). */
|
|
643
830
|
updateLimit(key, value) {
|
|
831
|
+
// Context keys live nested under config.contextManager and on the live
|
|
832
|
+
// ContextManager, not as top-level AgentConfig fields.
|
|
833
|
+
if (key === 'maxContextTokens') {
|
|
834
|
+
this.configuredContextTokens = value;
|
|
835
|
+
this.contextManager.setMaxContextTokens(value);
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
644
838
|
if (key in this.config) {
|
|
645
839
|
this.config[key] = value;
|
|
840
|
+
return true;
|
|
646
841
|
}
|
|
842
|
+
return false;
|
|
647
843
|
}
|
|
648
844
|
getReplay() { return this.replay; }
|
|
649
845
|
getChangeTracker() { return this.changeTracker; }
|
|
650
846
|
getHooks() { return this.hooks; }
|
|
847
|
+
/** How many project hooks the trust gate withheld. 0 when trusted. */
|
|
848
|
+
getSkippedHookCount() { return this.hooksSkipped; }
|
|
849
|
+
/**
|
|
850
|
+
* Re-read project hooks after the user trusts the workspace, so /trust picks
|
|
851
|
+
* them up in the same session rather than only after a restart. Returns the
|
|
852
|
+
* number now active.
|
|
853
|
+
*/
|
|
854
|
+
reloadHooks() {
|
|
855
|
+
if (!isWorkspaceTrusted())
|
|
856
|
+
return 0;
|
|
857
|
+
this.hooks = loadHooks();
|
|
858
|
+
this.hooksSkipped = 0;
|
|
859
|
+
return this.hooks.length;
|
|
860
|
+
}
|
|
651
861
|
getApiKey() { return this.config.apiKey; }
|
|
652
862
|
getBaseUrl() { return this.client.baseURL; }
|
|
653
863
|
getAgentId() { return this.agentId; }
|
|
@@ -820,6 +1030,8 @@ export class Agent extends EventEmitter {
|
|
|
820
1030
|
async send(content, images) {
|
|
821
1031
|
this._aborted = false;
|
|
822
1032
|
this.groundingNudgeCount = 0;
|
|
1033
|
+
if (images?.length)
|
|
1034
|
+
validatePastedImages(images);
|
|
823
1035
|
const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
|
|
824
1036
|
const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
|
|
825
1037
|
let finalContent = expandedContent;
|
|
@@ -842,12 +1054,30 @@ export class Agent extends EventEmitter {
|
|
|
842
1054
|
this.emit('thinking:start');
|
|
843
1055
|
this.truncateMessages();
|
|
844
1056
|
this.replay.record('user_message', { content });
|
|
1057
|
+
// Crew board turn-refresh: the system prompt is assembled once per
|
|
1058
|
+
// session, so a board that moved since then would go stale mid-thread.
|
|
1059
|
+
// Runs AFTER the push — send()'s synchronous contract (user message in
|
|
1060
|
+
// history and message:user emitted before the first await) is what the
|
|
1061
|
+
// IDE paints on — with the note spliced in BEFORE the user message, so
|
|
1062
|
+
// the member still reads the news before the ask. One file read per
|
|
1063
|
+
// turn; a workspace with no BOARD.md never produces a note.
|
|
1064
|
+
try {
|
|
1065
|
+
await this.boardPrimed;
|
|
1066
|
+
const boardNote = await this.boardRefresher.turnNote();
|
|
1067
|
+
if (boardNote) {
|
|
1068
|
+
const boardMessage = { role: 'system', content: boardNote };
|
|
1069
|
+
const idx = this.messages.lastIndexOf(userMessage);
|
|
1070
|
+
this.messages.splice(idx < 0 ? this.messages.length : idx, 0, boardMessage);
|
|
1071
|
+
this.replay.record('board_refresh', { chars: boardNote.length });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch { }
|
|
845
1075
|
const requestId = randomUUID().slice(0, 8);
|
|
846
1076
|
const reqLog = logger.child(requestId);
|
|
847
1077
|
reqLog.info('User message received', { length: content.length });
|
|
848
1078
|
this.registerInRegistry();
|
|
849
1079
|
try {
|
|
850
|
-
const initialModel = this.pickModel();
|
|
1080
|
+
const initialModel = this.pickModel(true);
|
|
851
1081
|
reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
|
|
852
1082
|
let result = await this.loopStreamingTurns(initialModel, reqLog);
|
|
853
1083
|
if (this.outputFormat !== 'text') {
|
|
@@ -866,7 +1096,7 @@ export class Agent extends EventEmitter {
|
|
|
866
1096
|
// platform key is a dead end that doesn't touch the credential that failed.
|
|
867
1097
|
if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
|
|
868
1098
|
error.message += this.capabilities.platformTools
|
|
869
|
-
? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys and update it via /setup.'
|
|
1099
|
+
? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli and update it via /setup.'
|
|
870
1100
|
: ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
|
|
871
1101
|
}
|
|
872
1102
|
error.metadata = {
|
|
@@ -891,6 +1121,13 @@ export class Agent extends EventEmitter {
|
|
|
891
1121
|
let emptyTurnCount = 0;
|
|
892
1122
|
const MAX_EMPTY_TURNS = 5;
|
|
893
1123
|
let disableTools = false;
|
|
1124
|
+
let _stepIndex = 0;
|
|
1125
|
+
let _turnToolDurationMs = 0;
|
|
1126
|
+
let _turnLlmStartMs = 0;
|
|
1127
|
+
let _turnTtftMs = 0;
|
|
1128
|
+
let _firstDeltaSeen = false;
|
|
1129
|
+
let _turnInputTokens = 0;
|
|
1130
|
+
let _turnOutputTokens = 0;
|
|
894
1131
|
let forceNonStreaming = false;
|
|
895
1132
|
while (turnCount < this.config.maxSteps) {
|
|
896
1133
|
if (this._aborted) {
|
|
@@ -946,6 +1183,27 @@ export class Agent extends EventEmitter {
|
|
|
946
1183
|
}
|
|
947
1184
|
}
|
|
948
1185
|
}
|
|
1186
|
+
// Scalix steering + live cloud-tools awareness, assembled here — the
|
|
1187
|
+
// one point every frontend (TUI, headless, IPC) funnels through —
|
|
1188
|
+
// and rebuilt per request so a /cloud refresh shows up on the next
|
|
1189
|
+
// turn. Coder is a generic tool: the product steering only appears
|
|
1190
|
+
// while a Scalix Cloud connection is live; on the Scalix provider
|
|
1191
|
+
// without one a single neutral pointer; on pure BYOK nothing at all.
|
|
1192
|
+
if (this.cloudStatus.state === 'connected') {
|
|
1193
|
+
if (!SCALIX_STEERING_ALWAYS_ON) {
|
|
1194
|
+
effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
|
|
1195
|
+
}
|
|
1196
|
+
// Deploy-path and platform facts ride with the connection too —
|
|
1197
|
+
// outside the ALWAYS_ON guard so flipping that flag never drops them.
|
|
1198
|
+
effectiveInstructions += '\n\n' + SCALIX_DEPLOY_PLAYBOOK_SECTION + '\n\n' + SCALIX_PLATFORM_PLAYBOOK_SECTION;
|
|
1199
|
+
const liveCloudTools = this.getCloudTools();
|
|
1200
|
+
if (liveCloudTools.length > 0) {
|
|
1201
|
+
effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
else if (!SCALIX_STEERING_ALWAYS_ON && this.providerId === 'scalix') {
|
|
1205
|
+
effectiveInstructions += '\n\n' + SCALIX_NEUTRAL_POINTER;
|
|
1206
|
+
}
|
|
949
1207
|
const vision = modelSupportsVision(currentModel);
|
|
950
1208
|
const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
|
|
951
1209
|
const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
|
|
@@ -958,6 +1216,7 @@ export class Agent extends EventEmitter {
|
|
|
958
1216
|
? true
|
|
959
1217
|
: (!apiTools || apiTools.length === 0) && !hasToolMessages);
|
|
960
1218
|
if (useStreaming) {
|
|
1219
|
+
_turnLlmStartMs = Date.now();
|
|
961
1220
|
const stream = await this.client.chatComplete({
|
|
962
1221
|
model: currentModel,
|
|
963
1222
|
messages: apiMessages,
|
|
@@ -977,6 +1236,10 @@ export class Agent extends EventEmitter {
|
|
|
977
1236
|
if (choice?.delta) {
|
|
978
1237
|
const delta = choice.delta;
|
|
979
1238
|
if (delta.content) {
|
|
1239
|
+
if (!_firstDeltaSeen) {
|
|
1240
|
+
_turnTtftMs = Date.now() - _turnLlmStartMs;
|
|
1241
|
+
_firstDeltaSeen = true;
|
|
1242
|
+
}
|
|
980
1243
|
fullText += delta.content;
|
|
981
1244
|
this.emit('stream:delta', delta.content, fullText);
|
|
982
1245
|
}
|
|
@@ -1000,6 +1263,8 @@ export class Agent extends EventEmitter {
|
|
|
1000
1263
|
const inTok = chunk.usage.prompt_tokens ?? 0;
|
|
1001
1264
|
const outTok = chunk.usage.completion_tokens ?? 0;
|
|
1002
1265
|
const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
|
|
1266
|
+
_turnInputTokens = inTok;
|
|
1267
|
+
_turnOutputTokens = outTok;
|
|
1003
1268
|
const cost = chunk.usage.cost ?? 0;
|
|
1004
1269
|
this.emit('usage', {
|
|
1005
1270
|
prompt_tokens: inTok,
|
|
@@ -1013,6 +1278,7 @@ export class Agent extends EventEmitter {
|
|
|
1013
1278
|
}
|
|
1014
1279
|
}
|
|
1015
1280
|
else {
|
|
1281
|
+
_turnLlmStartMs = Date.now();
|
|
1016
1282
|
this.emit('stream:start');
|
|
1017
1283
|
const result = await this.client.chatComplete({
|
|
1018
1284
|
model: currentModel,
|
|
@@ -1027,6 +1293,8 @@ export class Agent extends EventEmitter {
|
|
|
1027
1293
|
this.recordModelSuccess(currentModel);
|
|
1028
1294
|
const choice = result.choices?.[0];
|
|
1029
1295
|
if (choice?.message) {
|
|
1296
|
+
_turnTtftMs = Date.now() - _turnLlmStartMs;
|
|
1297
|
+
_firstDeltaSeen = true;
|
|
1030
1298
|
if (choice.message.content) {
|
|
1031
1299
|
fullText = choice.message.content;
|
|
1032
1300
|
this.emit('stream:delta', fullText, fullText);
|
|
@@ -1045,6 +1313,8 @@ export class Agent extends EventEmitter {
|
|
|
1045
1313
|
const inTok = result.usage.prompt_tokens ?? 0;
|
|
1046
1314
|
const outTok = result.usage.completion_tokens ?? 0;
|
|
1047
1315
|
const totalTok = result.usage.total_tokens ?? (inTok + outTok);
|
|
1316
|
+
_turnInputTokens = inTok;
|
|
1317
|
+
_turnOutputTokens = outTok;
|
|
1048
1318
|
const cost = result.usage.cost ?? 0;
|
|
1049
1319
|
this.emit('usage', {
|
|
1050
1320
|
prompt_tokens: inTok,
|
|
@@ -1219,10 +1489,13 @@ export class Agent extends EventEmitter {
|
|
|
1219
1489
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
1220
1490
|
if (tool && typeof execFn === 'function') {
|
|
1221
1491
|
try {
|
|
1222
|
-
//
|
|
1223
|
-
// authoritative (snake_case) and the
|
|
1224
|
-
// injecting camelCase duplicates could fail
|
|
1225
|
-
|
|
1492
|
+
// Remotely-schema'd tools get the raw args: their JSON Schemas
|
|
1493
|
+
// are authoritative (snake_case) and the far side validates
|
|
1494
|
+
// them — injecting camelCase duplicates could fail that
|
|
1495
|
+
// validation. True for the Scalix Cloud gateway and equally for
|
|
1496
|
+
// any MCP server, which validates against its own inputSchema.
|
|
1497
|
+
const src = tool?.source;
|
|
1498
|
+
const args = (src === 'scalix-cloud' || src === 'mcp')
|
|
1226
1499
|
? parsedArgs
|
|
1227
1500
|
: normalizeArgs(parsedArgs);
|
|
1228
1501
|
// Validate against the very schema the model was shown. Nothing
|
|
@@ -1249,6 +1522,8 @@ export class Agent extends EventEmitter {
|
|
|
1249
1522
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
1250
1523
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
1251
1524
|
: this.config.toolTimeoutMs;
|
|
1525
|
+
const toolStart = Date.now();
|
|
1526
|
+
this.emit('step:start', { stepIndex: _stepIndex, name: call.name, args: parsedArgs, startedAt: toolStart });
|
|
1252
1527
|
result = await withTimeout(execFn(args, {
|
|
1253
1528
|
context: {},
|
|
1254
1529
|
workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
|
|
@@ -1262,8 +1537,21 @@ export class Agent extends EventEmitter {
|
|
|
1262
1537
|
confirmToolCall: this.config.confirmToolCall,
|
|
1263
1538
|
},
|
|
1264
1539
|
}), toolTimeout, call.name);
|
|
1540
|
+
const toolDurationMs = Date.now() - toolStart;
|
|
1541
|
+
_turnToolDurationMs += toolDurationMs;
|
|
1542
|
+
this.emit('step:done', { stepIndex: _stepIndex, name: call.name, durationMs: toolDurationMs, success: !result?.error });
|
|
1543
|
+
_stepIndex++;
|
|
1544
|
+
// The tool only persists the entry to mcp-services.json. Connect
|
|
1545
|
+
// it here too, so the server's tools are usable in this same
|
|
1546
|
+
// session instead of only after a restart — and report the
|
|
1547
|
+
// outcome back through the tool result, since a server that
|
|
1548
|
+
// won't answer is something the model needs to know now.
|
|
1265
1549
|
if (call.name === 'add_mcp_server' && result?.success) {
|
|
1266
|
-
|
|
1550
|
+
const connected = await this.connectMcpServer({
|
|
1551
|
+
serverLabel: args.label,
|
|
1552
|
+
serverUrl: args.url,
|
|
1553
|
+
});
|
|
1554
|
+
result = { ...result, connected: connected.state === 'connected', tools: connected.toolCount, ...(connected.error ? { connectError: connected.error } : {}) };
|
|
1267
1555
|
}
|
|
1268
1556
|
}
|
|
1269
1557
|
catch (e) {
|
|
@@ -1272,11 +1560,19 @@ export class Agent extends EventEmitter {
|
|
|
1272
1560
|
}
|
|
1273
1561
|
}
|
|
1274
1562
|
else {
|
|
1275
|
-
const
|
|
1563
|
+
const allNames = this.config.tools
|
|
1276
1564
|
.map((t) => t.function?.name || t.name)
|
|
1277
|
-
.filter(Boolean)
|
|
1278
|
-
|
|
1279
|
-
|
|
1565
|
+
.filter(Boolean);
|
|
1566
|
+
// Near-matches first: an invented "scalix_build_list" must surface
|
|
1567
|
+
// the real scalix_* names. Blindly listing the first 20 registry
|
|
1568
|
+
// entries hid every platform tool and read as "those don't exist" —
|
|
1569
|
+
// the model then invented workarounds instead of correcting the name
|
|
1570
|
+
// (seen 2026-08-09: a deploy derailed into "make the repo public").
|
|
1571
|
+
const stem = call.name.toLowerCase().split(/[_-]/)[0];
|
|
1572
|
+
const near = stem.length >= 3 ? allNames.filter((n) => n.toLowerCase().includes(stem)) : [];
|
|
1573
|
+
const shown = [...new Set([...near, ...allNames])].slice(0, Math.max(20, Math.min(near.length, 40)));
|
|
1574
|
+
const omitted = allNames.length - shown.length;
|
|
1575
|
+
result = { error: `Tool "${call.name}" does not exist.${near.length ? ` Did you mean one of: ${near.slice(0, 40).join(', ')}?` : ''} Available tools: ${shown.join(', ')}${omitted > 0 ? `, plus ${omitted} more registered but not listed here` : ''}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
|
|
1280
1576
|
}
|
|
1281
1577
|
let resultStr;
|
|
1282
1578
|
try {
|
|
@@ -1382,6 +1678,16 @@ export class Agent extends EventEmitter {
|
|
|
1382
1678
|
}
|
|
1383
1679
|
if (this._aborted)
|
|
1384
1680
|
return '[interrupted]';
|
|
1681
|
+
this.emit('turn:summary', {
|
|
1682
|
+
turnIndex: turnCount,
|
|
1683
|
+
steps: _stepIndex,
|
|
1684
|
+
llmDurationMs: Date.now() - _turnLlmStartMs,
|
|
1685
|
+
toolDurationMs: _turnToolDurationMs,
|
|
1686
|
+
ttftMs: _turnTtftMs,
|
|
1687
|
+
tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
|
|
1688
|
+
inputTokens: _turnInputTokens,
|
|
1689
|
+
outputTokens: _turnOutputTokens,
|
|
1690
|
+
});
|
|
1385
1691
|
continue;
|
|
1386
1692
|
}
|
|
1387
1693
|
if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
|
|
@@ -1401,6 +1707,16 @@ export class Agent extends EventEmitter {
|
|
|
1401
1707
|
}
|
|
1402
1708
|
}
|
|
1403
1709
|
}
|
|
1710
|
+
this.emit('turn:summary', {
|
|
1711
|
+
turnIndex: turnCount,
|
|
1712
|
+
steps: _stepIndex,
|
|
1713
|
+
llmDurationMs: Date.now() - _turnLlmStartMs,
|
|
1714
|
+
toolDurationMs: _turnToolDurationMs,
|
|
1715
|
+
ttftMs: _turnTtftMs,
|
|
1716
|
+
tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
|
|
1717
|
+
inputTokens: _turnInputTokens,
|
|
1718
|
+
outputTokens: _turnOutputTokens,
|
|
1719
|
+
});
|
|
1404
1720
|
return fullText;
|
|
1405
1721
|
}
|
|
1406
1722
|
catch (err) {
|