scalix 1.2.0 → 1.4.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/CHANGELOG.md +27 -0
- package/README.md +13 -2
- package/bin/scalix.js +8 -0
- package/dist/agent.js +148 -32
- package/dist/api-client.js +49 -11
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +582 -173
- package/dist/cloud.js +236 -0
- package/dist/commands.js +19 -2
- package/dist/constants.js +138 -11
- 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 +102 -45
- 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 +93 -10
- 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-voice.js +9 -15
- package/dist/tools-web.js +81 -54
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Scalix Coder will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-07-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Scalix Cloud platform tools in the agent — your account's MCP catalog (`GET /api/v1/mcp`, scope-filtered per key) is fetched on start and registered as first-class function tools, executed via `POST /api/v1/mcp/call` with your own API key. Deploys, database queries, storage, functions and more become plain agent prompts
|
|
9
|
+
- `/cloud` command — `/cloud status` (identity, plan, limits, tool count), `/cloud tools` (available `scalix_*` tools grouped by platform area), `/cloud refresh` (re-fetch identity + catalog)
|
|
10
|
+
- `/login` command — links your Scalix account through the setup wizard
|
|
11
|
+
- Connected onboarding — the setup wizard validates the API key against `GET /v1/me` and shows a "Connected to Scalix Cloud" panel (org, project, plan, rate limit, platform tools unlocked) with a re-enter/skip choice on failure; a "Try:" block with real platform prompts appears after a successful connect
|
|
12
|
+
- Welcome/status line reflects the real connection: `☁ Scalix Cloud · <plan> · <N> platform tools` when connected, `☁ not connected — /login to link your Scalix account` otherwise
|
|
13
|
+
- `SCALIX_PROJECT` env var — sent as `x-scalix-project` on catalog/tool calls to scope an org-wide key to one project
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- When the catalog is unreachable or no key is configured, the agent states it once and runs with local tools only — availability is never fabricated and there is no retry loop. Tool counts in the status bar and headless banner include only tools that actually loaded
|
|
17
|
+
|
|
18
|
+
## [1.3.3] - 2026-07-05
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- API endpoints aligned to the Scalix gateway's `/v1/ai` nest: chat completions now POST to `/v1/ai/chat/completions` and the model list is fetched from `GET /v1/ai/models` (OpenAI-style `{data: [...]}`). The default base URL is now the host root `https://api.scalix.world`; a trailing `/v1` in existing configs is still accepted and normalized
|
|
22
|
+
- Voice input calls the single canonical `/v1/ai/audio/transcriptions` path and reports plainly when transcription isn't available on the deployment, instead of probing two dead endpoints
|
|
23
|
+
- Web search no longer calls a non-existent platform research endpoint; it states that web research isn't available via the Scalix API yet and uses the configured search providers (Brave/Tavily/DuckDuckGo)
|
|
24
|
+
- Sandbox no longer falsely blocks file access when the working directory sits behind a symlink (e.g. macOS temp dirs): symlink containment now compares real paths on both sides
|
|
25
|
+
- `User-Agent` header carries the real package version instead of a hardcoded `1.0`
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- Model examples and defaults use current Scalix model names (`scalix-lumio-*`); the retired `scalix-world-ai` id removed from UI text and defaults
|
|
29
|
+
- Model list UI no longer shows tier/provider columns (the API does not expose them)
|
|
30
|
+
- README version badge matches the released package version
|
|
31
|
+
|
|
5
32
|
## [3.1.0] - 2026-04-20
|
|
6
33
|
|
|
7
34
|
### Added
|
package/README.md
CHANGED
|
@@ -2,11 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
AI-powered coding agent for the terminal and VS Code — built by [Scalix World](https://scalix.world).
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or
|
|
9
|
+
Scalix Coder is a full-featured AI coding assistant that runs in your terminal or as a VS Code extension. It connects to the Scalix API (or your own compatible endpoint) and gives AI direct access to your codebase through 31 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
|
|
10
|
+
|
|
11
|
+
## One agent for your whole cloud
|
|
12
|
+
|
|
13
|
+
With a Scalix account, the agent is more than a coder: on start it fetches your account's platform tool catalog and registers every tool your API key's scopes unlock — databases, deployments (Scalix Run), storage, functions, cron, domains, and more. "Deploy this app", "create a table and seed it", "upload dist/ to a bucket" become plain prompts.
|
|
14
|
+
|
|
15
|
+
- Tools reflect **your key's scopes** — the catalog is filtered server-side, and mutating tools are refused for read-only keys
|
|
16
|
+
- `/cloud` shows your connection, plan, and available tools; `/cloud refresh` re-fetches the catalog
|
|
17
|
+
- No account (or catalog unreachable)? The agent says so once and runs in **local-only mode** with the built-in tools — nothing is faked
|
|
10
18
|
|
|
11
19
|
## Features
|
|
12
20
|
|
|
@@ -55,6 +63,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
|
|
|
55
63
|
| `SCALIX_API_KEY` | API key (overrides saved config) |
|
|
56
64
|
| `SCALIX_MODEL` | Comma-separated model IDs |
|
|
57
65
|
| `SCALIX_BASE_URL` | API base URL |
|
|
66
|
+
| `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
|
|
58
67
|
| `BRAVE_API_KEY` | Brave Search API key |
|
|
59
68
|
| `TAVILY_API_KEY` | Tavily Search API key |
|
|
60
69
|
|
|
@@ -90,6 +99,8 @@ scalix run "generate a migration" --output migration.sql --format patch
|
|
|
90
99
|
| `/replay` | Session timeline |
|
|
91
100
|
| `/plugins` | Loaded plugins |
|
|
92
101
|
| `/settings` | Configure limits |
|
|
102
|
+
| `/cloud` | Scalix Cloud status, platform tools & refresh |
|
|
103
|
+
| `/login` | Link your Scalix account |
|
|
93
104
|
| `/setup` | Reconfigure API key & models |
|
|
94
105
|
| `/delegate <task>` | Run with sub-agent |
|
|
95
106
|
| `/checkpoint` | Create git checkpoint |
|
package/bin/scalix.js
CHANGED
|
@@ -8,6 +8,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
process.env.NODE_ENV = 'production';
|
|
9
9
|
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
13
|
+
const { readFileSync } = await import('node:fs');
|
|
14
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
15
|
+
console.log(pkg.version);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const needsHeadless =
|
|
12
20
|
args.includes('run') ||
|
|
13
21
|
args.includes('--pipe') || args.includes('-p') ||
|
package/dist/agent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.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
7
|
import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
|
|
8
8
|
import { ContextManager } from './context-manager.js';
|
|
9
9
|
import { zodToJsonSchema } from './tool-helper.js';
|
|
@@ -19,8 +19,13 @@ 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
23
|
function transformToolsForApi(tools) {
|
|
23
24
|
return tools
|
|
25
|
+
// type:'mcp' entries are decorative config for user-registered external
|
|
26
|
+
// MCP servers and never reach the API. Scalix Cloud platform tools bypass
|
|
27
|
+
// this path entirely: they are plain function tools (CloudPlatformTool,
|
|
28
|
+
// registered by refreshCloudTools) whose `parameters` pass through as-is.
|
|
24
29
|
.filter((t) => !('type' in t && t.type === 'mcp'))
|
|
25
30
|
.map((t) => {
|
|
26
31
|
const local = t;
|
|
@@ -156,12 +161,16 @@ export class Agent extends EventEmitter {
|
|
|
156
161
|
groundingConfig = null;
|
|
157
162
|
groundingNudgeCount = 0;
|
|
158
163
|
retrievalConfig = null;
|
|
164
|
+
_sending = false;
|
|
165
|
+
outputFormatInstructionIndex = null;
|
|
166
|
+
cloudProjectId;
|
|
167
|
+
cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
159
168
|
constructor(config) {
|
|
160
169
|
super();
|
|
161
170
|
this.agentId = config.agentId ?? generateAgentId();
|
|
162
171
|
this.parentAgentId = config.parentAgentId ?? null;
|
|
163
172
|
this.agentDescription = config.agentDescription ?? 'main';
|
|
164
|
-
this.client = new ScalixClient(config.baseUrl ||
|
|
173
|
+
this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
|
|
165
174
|
this.log = logger.child();
|
|
166
175
|
const resolvedModels = Array.isArray(config.model)
|
|
167
176
|
? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
|
|
@@ -185,7 +194,9 @@ export class Agent extends EventEmitter {
|
|
|
185
194
|
circuitBreakerResetMs: config.circuitBreakerResetMs ?? AGENT_DEFAULTS.circuitBreakerResetMs,
|
|
186
195
|
fileRefTruncation: config.fileRefTruncation ?? TOOL_DEFAULTS.fileRefTruncation,
|
|
187
196
|
thinkingMode: config.thinkingMode ?? 'off',
|
|
197
|
+
workspaceRoot: config.workspaceRoot ?? process.cwd(),
|
|
188
198
|
};
|
|
199
|
+
this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
|
|
189
200
|
this.contextManager = new ContextManager(config.contextManager);
|
|
190
201
|
if (config.dynamicToolSelection) {
|
|
191
202
|
this.toolSelector = new ToolSelector();
|
|
@@ -252,22 +263,22 @@ export class Agent extends EventEmitter {
|
|
|
252
263
|
shouldFallbackToNext(err) {
|
|
253
264
|
const e = err;
|
|
254
265
|
const message = (e?.message || e?.error?.message || '').toLowerCase();
|
|
255
|
-
|
|
256
|
-
|
|
266
|
+
// Prefer the structured status carried by ApiError; fall back to other shapes.
|
|
267
|
+
const status = err instanceof ApiError ? err.status : (e?.status ?? e?.statusCode ?? e?.error?.code);
|
|
268
|
+
if (status === 401 || status === 403 || message.includes('unauthorized') || message.includes('forbidden') || message.includes('invalid api key')) {
|
|
257
269
|
return false;
|
|
258
270
|
}
|
|
271
|
+
// Retry/fallback on transient upstream statuses.
|
|
272
|
+
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 408 || status === 404) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
259
275
|
return (message.includes('429') ||
|
|
260
276
|
message.includes('rate limit') ||
|
|
261
277
|
message.includes('provider returned error') ||
|
|
262
278
|
message.includes('safety') ||
|
|
263
279
|
message.includes('streaming failed') ||
|
|
264
280
|
message.includes('stream error') ||
|
|
265
|
-
message.includes('try again')
|
|
266
|
-
statusCode === 429 ||
|
|
267
|
-
statusCode === 500 ||
|
|
268
|
-
statusCode === 502 ||
|
|
269
|
-
statusCode === 503 ||
|
|
270
|
-
statusCode === 404);
|
|
281
|
+
message.includes('try again'));
|
|
271
282
|
}
|
|
272
283
|
isModelCircuitOpen(model) {
|
|
273
284
|
const state = this.modelFailures.get(model);
|
|
@@ -294,6 +305,10 @@ export class Agent extends EventEmitter {
|
|
|
294
305
|
const keepCount = Math.floor(this.config.maxMessages * this.config.messageKeepRatio);
|
|
295
306
|
const dropped = this.messages.length - keepCount;
|
|
296
307
|
this.messages = this.messages.slice(dropped);
|
|
308
|
+
// Drop leading orphan tool messages — a tool result whose preceding
|
|
309
|
+
// assistant tool_call was sliced away would be rejected by the API.
|
|
310
|
+
while (this.messages[0]?.role === 'tool')
|
|
311
|
+
this.messages.shift();
|
|
297
312
|
this.log.info(`Truncated ${dropped} oldest messages, keeping ${this.messages.length}`);
|
|
298
313
|
}
|
|
299
314
|
emitThinkingEnd() {
|
|
@@ -331,7 +346,7 @@ export class Agent extends EventEmitter {
|
|
|
331
346
|
model,
|
|
332
347
|
messages: apiMessages,
|
|
333
348
|
stream: false,
|
|
334
|
-
});
|
|
349
|
+
}, this._abortController?.signal);
|
|
335
350
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
336
351
|
if (!summary)
|
|
337
352
|
return 'Compaction failed: no summary generated.';
|
|
@@ -386,7 +401,7 @@ export class Agent extends EventEmitter {
|
|
|
386
401
|
{ role: 'user', content: prompt },
|
|
387
402
|
],
|
|
388
403
|
stream: false,
|
|
389
|
-
});
|
|
404
|
+
}, this._abortController?.signal);
|
|
390
405
|
const summary = result.choices?.[0]?.message?.content?.trim() || '';
|
|
391
406
|
if (summary) {
|
|
392
407
|
this.messages = [
|
|
@@ -415,6 +430,82 @@ export class Agent extends EventEmitter {
|
|
|
415
430
|
if (this.toolSelector)
|
|
416
431
|
this.toolSelector.index(this.config.tools);
|
|
417
432
|
}
|
|
433
|
+
// ─── Scalix Cloud platform tools ───
|
|
434
|
+
isCloudTool(t) {
|
|
435
|
+
return 'source' in t && t.source === 'scalix-cloud';
|
|
436
|
+
}
|
|
437
|
+
/** Build a first-class function tool from a catalog entry. The server's JSON
|
|
438
|
+
* Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
|
|
439
|
+
* which transformToolsForApi would run through the Zod converter). */
|
|
440
|
+
makeCloudTool(ct) {
|
|
441
|
+
const baseUrl = this.client.baseURL;
|
|
442
|
+
const apiKey = this.config.apiKey;
|
|
443
|
+
const projectId = this.cloudProjectId;
|
|
444
|
+
return {
|
|
445
|
+
source: 'scalix-cloud',
|
|
446
|
+
name: ct.name,
|
|
447
|
+
description: ct.description,
|
|
448
|
+
parameters: ct.inputSchema,
|
|
449
|
+
execute: async (args) => {
|
|
450
|
+
const result = await callCloudTool(baseUrl, apiKey, ct.name, args ?? {}, projectId);
|
|
451
|
+
// Tool-level failures are tool results the model can react to, not
|
|
452
|
+
// exceptions — the "Error: " prefix is the model-facing signal.
|
|
453
|
+
return result.ok ? result.text : `Error: ${result.text}`;
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Fetch the account's Scalix Cloud tool catalog and register every returned
|
|
459
|
+
* tool as a first-class function tool. Called on agent start and on demand
|
|
460
|
+
* (/cloud refresh). On failure the previously registered cloud tools are
|
|
461
|
+
* removed and the agent keeps running with local tools only — the returned
|
|
462
|
+
* status carries the real error for a single honest notice; no retries.
|
|
463
|
+
*/
|
|
464
|
+
async refreshCloudTools() {
|
|
465
|
+
if (!this.config.apiKey) {
|
|
466
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
467
|
+
this.cloudStatus = { state: 'no-key', toolCount: 0 };
|
|
468
|
+
return { ...this.cloudStatus };
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
|
|
472
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
473
|
+
const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
|
|
474
|
+
let registered = 0;
|
|
475
|
+
for (const ct of catalog.tools) {
|
|
476
|
+
if (localNames.has(ct.name)) {
|
|
477
|
+
this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
this.config.tools.push(this.makeCloudTool(ct));
|
|
481
|
+
registered++;
|
|
482
|
+
}
|
|
483
|
+
if (this.toolSelector)
|
|
484
|
+
this.toolSelector.index(this.config.tools);
|
|
485
|
+
this.cloudStatus = {
|
|
486
|
+
state: 'connected',
|
|
487
|
+
toolCount: registered,
|
|
488
|
+
platformAvailable: catalog.platformAvailable,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
|
|
493
|
+
if (this.toolSelector)
|
|
494
|
+
this.toolSelector.index(this.config.tools);
|
|
495
|
+
this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
|
|
496
|
+
this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
|
|
497
|
+
}
|
|
498
|
+
return { ...this.cloudStatus };
|
|
499
|
+
}
|
|
500
|
+
getCloudToolsStatus() {
|
|
501
|
+
return { ...this.cloudStatus };
|
|
502
|
+
}
|
|
503
|
+
getCloudTools() {
|
|
504
|
+
return this.config.tools.filter((t) => this.isCloudTool(t));
|
|
505
|
+
}
|
|
506
|
+
getCloudProjectId() {
|
|
507
|
+
return this.cloudProjectId;
|
|
508
|
+
}
|
|
418
509
|
addModel(model) {
|
|
419
510
|
if (!this.config.models.includes(model))
|
|
420
511
|
this.config.models.push(model);
|
|
@@ -451,6 +542,9 @@ export class Agent extends EventEmitter {
|
|
|
451
542
|
deregisterFromRegistry(status) {
|
|
452
543
|
if (!this.registeredInRegistry)
|
|
453
544
|
return;
|
|
545
|
+
// Reset so a fresh send() re-registers, and guard against double-deregister
|
|
546
|
+
// (e.g. an aborted turn followed by the send() catch/finally path).
|
|
547
|
+
this.registeredInRegistry = false;
|
|
454
548
|
AgentRegistry.getInstance().deregister(this.agentId, status);
|
|
455
549
|
}
|
|
456
550
|
checkInbox() {
|
|
@@ -540,7 +634,15 @@ export class Agent extends EventEmitter {
|
|
|
540
634
|
else {
|
|
541
635
|
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
636
|
}
|
|
637
|
+
// Inject the format instruction only once per session — refresh the existing
|
|
638
|
+
// system message in place if it's still valid, otherwise append once.
|
|
639
|
+
const existing = this.outputFormatInstructionIndex;
|
|
640
|
+
if (existing !== null && this.messages[existing]?.role === 'system') {
|
|
641
|
+
this.messages[existing] = { role: 'system', content: instruction };
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
543
644
|
this.messages.push({ role: 'system', content: instruction });
|
|
645
|
+
this.outputFormatInstructionIndex = this.messages.length - 1;
|
|
544
646
|
}
|
|
545
647
|
extractJsonFromResponse(response) {
|
|
546
648
|
const trimmed = response.trim();
|
|
@@ -868,7 +970,28 @@ export class Agent extends EventEmitter {
|
|
|
868
970
|
continue;
|
|
869
971
|
}
|
|
870
972
|
}
|
|
871
|
-
|
|
973
|
+
// Parse tool arguments once, up front, so the permission decision,
|
|
974
|
+
// the confirmation prompt, and execution all see the same values.
|
|
975
|
+
let parsedArgs;
|
|
976
|
+
const rawArgs = (call.arguments ?? '').trim();
|
|
977
|
+
if (rawArgs === '') {
|
|
978
|
+
parsedArgs = {};
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
try {
|
|
982
|
+
parsedArgs = JSON.parse(call.arguments);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
result = { error: 'Malformed JSON arguments — resend the tool call with valid JSON' };
|
|
986
|
+
reqLog.warn('Malformed tool arguments', { tool: call.name });
|
|
987
|
+
this.emit('tool:blocked', call.name, 'Malformed arguments');
|
|
988
|
+
this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
|
|
989
|
+
this.emit('tool:result', call.name, result);
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// Args-aware permission so scoped allow/deny rules are enforced.
|
|
994
|
+
const permission = getToolPermissionWithArgs(call.name, this.config.permissionMode, parsedArgs);
|
|
872
995
|
if (permission === 'block') {
|
|
873
996
|
result = { denied: true, message: `Tool ${call.name} is blocked in ${this.config.permissionMode} mode` };
|
|
874
997
|
reqLog.info('Tool blocked by permission mode', { tool: call.name, mode: this.config.permissionMode });
|
|
@@ -884,8 +1007,8 @@ export class Agent extends EventEmitter {
|
|
|
884
1007
|
}
|
|
885
1008
|
if (permission === 'ask') {
|
|
886
1009
|
if (!this.config.confirmToolCall) {
|
|
887
|
-
if (this.config.permissionMode === '
|
|
888
|
-
reqLog.info('Auto-approving tool (no confirmation handler
|
|
1010
|
+
if (this.config.permissionMode === 'yolo') {
|
|
1011
|
+
reqLog.info('Auto-approving tool (yolo mode, no confirmation handler)', { tool: call.name });
|
|
889
1012
|
}
|
|
890
1013
|
else {
|
|
891
1014
|
result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
|
|
@@ -902,13 +1025,7 @@ export class Agent extends EventEmitter {
|
|
|
902
1025
|
}
|
|
903
1026
|
}
|
|
904
1027
|
else {
|
|
905
|
-
|
|
906
|
-
try {
|
|
907
|
-
args = JSON.parse(call.arguments);
|
|
908
|
-
}
|
|
909
|
-
catch {
|
|
910
|
-
args = call.arguments;
|
|
911
|
-
}
|
|
1028
|
+
const args = parsedArgs;
|
|
912
1029
|
const CONFIRM_TIMEOUT_MS = 300_000;
|
|
913
1030
|
const confirmed = await new Promise((resolve) => {
|
|
914
1031
|
const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
|
|
@@ -940,20 +1057,19 @@ export class Agent extends EventEmitter {
|
|
|
940
1057
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
941
1058
|
if (tool && typeof execFn === 'function') {
|
|
942
1059
|
try {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
1060
|
+
// Cloud platform tools get the raw args: their JSON Schemas are
|
|
1061
|
+
// authoritative (snake_case) and the gateway validates them —
|
|
1062
|
+
// injecting camelCase duplicates could fail server-side validation.
|
|
1063
|
+
const args = tool?.source === 'scalix-cloud'
|
|
1064
|
+
? parsedArgs
|
|
1065
|
+
: normalizeArgs(parsedArgs);
|
|
950
1066
|
reqLog.debug('Executing tool', { tool: call.name });
|
|
951
1067
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
952
1068
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
953
1069
|
: this.config.toolTimeoutMs;
|
|
954
1070
|
result = await withTimeout(execFn(args, {
|
|
955
1071
|
context: {},
|
|
956
|
-
workspaceRoot: process.cwd(),
|
|
1072
|
+
workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
|
|
957
1073
|
permissionMode: this.config.permissionMode,
|
|
958
1074
|
agentId: this.agentId,
|
|
959
1075
|
agentConfig: {
|
package/dist/api-client.js
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
|
+
import { USER_AGENT, aiEndpointUrl } from './constants.js';
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
body;
|
|
5
|
+
constructor(status, statusText, body) {
|
|
6
|
+
super(`API request failed: ${status} ${statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`);
|
|
7
|
+
this.name = 'ApiError';
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.body = body;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
1
13
|
export class ScalixClient {
|
|
2
14
|
baseURL;
|
|
3
15
|
apiKey;
|
|
4
|
-
|
|
16
|
+
timeoutMs;
|
|
17
|
+
constructor(baseURL, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
5
18
|
this.baseURL = baseURL.replace(/\/+$/, '');
|
|
6
19
|
this.apiKey = apiKey;
|
|
20
|
+
this.timeoutMs = timeoutMs;
|
|
7
21
|
}
|
|
8
22
|
async chatComplete(req, signal) {
|
|
9
|
-
|
|
23
|
+
// baseURL is the API host root; the gateway nests AI under /v1/ai.
|
|
24
|
+
// aiEndpointUrl() also tolerates a legacy base ending in /v1.
|
|
25
|
+
const url = aiEndpointUrl(this.baseURL, 'chat/completions');
|
|
10
26
|
const body = JSON.stringify(req);
|
|
27
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
28
|
+
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
11
29
|
const response = await fetch(url, {
|
|
12
30
|
method: 'POST',
|
|
13
31
|
headers: {
|
|
14
32
|
'Content-Type': 'application/json',
|
|
15
33
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
16
|
-
'User-Agent':
|
|
34
|
+
'User-Agent': USER_AGENT,
|
|
17
35
|
},
|
|
18
36
|
body,
|
|
19
|
-
signal,
|
|
37
|
+
signal: combined,
|
|
20
38
|
});
|
|
21
39
|
if (!response.ok) {
|
|
22
40
|
const text = await response.text().catch(() => '');
|
|
23
|
-
throw new
|
|
41
|
+
throw new ApiError(response.status, response.statusText, text);
|
|
24
42
|
}
|
|
25
43
|
if (req.stream) {
|
|
26
44
|
return this.parseSSEStream(response);
|
|
@@ -45,32 +63,52 @@ export class ScalixClient {
|
|
|
45
63
|
const trimmed = line.trim();
|
|
46
64
|
if (!trimmed || trimmed.startsWith(':'))
|
|
47
65
|
continue;
|
|
48
|
-
if (trimmed.startsWith('data:
|
|
49
|
-
const data = trimmed.slice(
|
|
66
|
+
if (trimmed.startsWith('data:')) {
|
|
67
|
+
const data = trimmed.slice(5).replace(/^ /, '');
|
|
50
68
|
if (data === '[DONE]')
|
|
51
69
|
return;
|
|
70
|
+
let parsed;
|
|
52
71
|
try {
|
|
53
|
-
|
|
72
|
+
parsed = JSON.parse(data);
|
|
54
73
|
}
|
|
55
74
|
catch {
|
|
56
75
|
// skip malformed chunks
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (parsed && parsed.error) {
|
|
79
|
+
const err = parsed.error;
|
|
80
|
+
throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
|
|
57
81
|
}
|
|
82
|
+
yield parsed;
|
|
58
83
|
}
|
|
59
84
|
}
|
|
60
85
|
}
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
const tail = buffer.trim();
|
|
87
|
+
if (tail.startsWith('data:')) {
|
|
88
|
+
const data = tail.slice(5).replace(/^ /, '');
|
|
63
89
|
if (data !== '[DONE]') {
|
|
90
|
+
let parsed;
|
|
64
91
|
try {
|
|
65
|
-
|
|
92
|
+
parsed = JSON.parse(data);
|
|
66
93
|
}
|
|
67
94
|
catch {
|
|
68
95
|
// skip
|
|
69
96
|
}
|
|
97
|
+
if (parsed) {
|
|
98
|
+
if (parsed.error) {
|
|
99
|
+
const err = parsed.error;
|
|
100
|
+
throw new Error(`Stream error: ${typeof err === 'string' ? err : (err?.message || JSON.stringify(err))}`);
|
|
101
|
+
}
|
|
102
|
+
yield parsed;
|
|
103
|
+
}
|
|
70
104
|
}
|
|
71
105
|
}
|
|
72
106
|
}
|
|
73
107
|
finally {
|
|
108
|
+
try {
|
|
109
|
+
await reader.cancel();
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
74
112
|
reader.releaseLock();
|
|
75
113
|
}
|
|
76
114
|
}
|
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 {
|