scalix 1.5.2 → 1.6.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 +40 -0
- package/README.md +44 -1
- package/dist/agent.js +140 -26
- package/dist/api-client.js +62 -3
- package/dist/cli.js +131 -4
- package/dist/command-sandbox.js +146 -0
- package/dist/commands.js +18 -6
- package/dist/constants.js +8 -0
- package/dist/cost-tracker.js +1 -1
- package/dist/headless.js +21 -4
- package/dist/ipc-server.js +42 -11
- package/dist/marketplace.js +400 -0
- package/dist/mcp.js +45 -18
- package/dist/providers/index.js +21 -4
- package/dist/secure-fetch.js +117 -0
- package/dist/settings.js +193 -44
- package/dist/slash-commands.js +2 -0
- package/dist/sub-agent.js +3 -0
- package/dist/tools-browser.js +8 -2
- package/dist/tools-extra.js +86 -4
- package/dist/tools-web.js +26 -23
- package/dist/tools.js +84 -9
- package/dist/workspace-trust.js +5 -1
- package/dist/worktree.js +24 -5
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Scalix Coder will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.6.0] - 2026-11-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`/marketplace` — the Scalix curated plugin catalog.** Browse, inspect and install catalog listings from the TUI: MCP servers into `mcp-services.json`, skills into `.scalix/skills/`, rules into `.scalix/rules/`, subagents into `.scalix/agents/`. Every listing is re-validated client-side before anything touches disk (https-only public-host remote entries, bare stdio command names, relative file paths, env-var names only — never credential values), stdio installs require explicit confirmation, and install telemetry is best-effort and anonymous. New module `src/marketplace.ts`; 21 unit tests.
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- **Repo-supplied slash commands are trust-gated and run with a sanitized environment.** `.scalix/commands/*.md` templates were loaded in every front end without the workspace-trust check that plugins and hooks get, and their `` !`cmd` `` inline-bash expansions executed with the full parent environment — API keys readable. Inline bash now runs with `sanitizeEnvForCommand()`, loading is gated on `isWorkspaceTrusted()` (with a skip notice), and a repo carrying `commands/` now triggers the trust prompt. (Audit F1)
|
|
12
|
+
- **`browser_session_save` / `restore` reject traversal names.** The `name` argument became a filename via `path.join` without validation, so `../../..` wrote and read JSON outside the sandbox. Names are now restricted to `[A-Za-z0-9._-]` at the schema and in `saveSessionState`. (F2)
|
|
13
|
+
- **`search_files` ripgrep fast path is sandbox-contained.** The rg branch took any absolute `path` and read it; the JS fallback already validated. The target now goes through `sandboxPath()` before any backend runs, with rg output relativized to preserve the old shape. (F3)
|
|
14
|
+
- **Project `settings.local.json` can no longer escalate permissions.** A trusted repo could set `permissionMode: "yolo"`, reclassify tools via `toolOverrides`, disable blocklist/injection patterns, or add scoped "allow" rules. Project files may now only tighten: `plan` mode, deny-only scoped rules, and a whitelist of tightening security fields; escalation attempts are ignored with a notice. Managed settings (`managed-settings.json`) are now validated with the same validators as user config. (F4)
|
|
15
|
+
- **`@file` prompt references are sandbox-contained.** `@/abs/path.ext` in a prompt read any file on disk in the TUI and was attacker-influenceable via `--pipe`; both `agent.ts` and `commands.ts` expansion now route through the sandbox check. (F5)
|
|
16
|
+
- **Alibaba Cloud metadata IP `100.100.100.200` is treated as private** by `web_fetch` and the browser tools — it sits outside every previously-listed range and exposed instance credentials on ECS. (F9)
|
|
17
|
+
- MCP `serverUrl` values are redacted (userinfo stripped) before logging; MCP fetch now downgrades 301/302/303 redirects of POST requests to GET and drops the body per the fetch spec. (F12 + redirect semantics)
|
|
18
|
+
- IPC `setPermissionMode` validates the mode against `PERMISSION_MODES` instead of applying any string. (F14)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- **A user abort no longer pollutes the model circuit breaker.** Aborting mid-request was recorded as a model failure — five Escapes within the reset window silently rotated the model. The abort path now returns `'[interrupted]'` consistently whether it lands between tool calls or mid-request. (Audit: agent abort contract)
|
|
22
|
+
- **Retry backoff, stream-fallback waits, and tool-confirmation prompts are abort-aware.** Cancelling during a Retry-After wait (up to 60s) or a pending confirmation (up to 300s) returns immediately instead of parking the loop.
|
|
23
|
+
- **Conflicted sub-agent worktree merges keep their branch.** Teardown ran `git branch -D` unconditionally, destroying the sub-agent's unmerged commits right after reporting the conflict; removal now uses a safe delete and keeps unmerged `scalix-agent/*` branches for recovery. A failed merge probe is reported as a failure (with `error`) instead of `{merged: true, commitCount: 0}`.
|
|
24
|
+
- **`/delegate` works on BYOK sessions.** The headless sub-agent config omitted `provider` (and cloud credentials), so the sub-agent called the Scalix URL shape against a third-party base URL and 404'd on every request.
|
|
25
|
+
- **Per-request timeout defaults to 300s.** The comment promised it but unset `SCALIX_REQUEST_TIMEOUT_MS` meant *no* timeout; a wedged connection hung the turn forever.
|
|
26
|
+
- The capability probe (`streamsToolCalls`) can no longer fail a turn — errors default to the conservative non-streaming answer.
|
|
27
|
+
- `clearHistory()` / `loadMessages()` reset the output-format instruction index, so a `/load` followed by a JSON-format request can no longer overwrite an unrelated system message.
|
|
28
|
+
- Streaming TTFT and per-turn metrics reset each loop iteration, so `turn:summary` reports the turn that produced the final response.
|
|
29
|
+
- `scalix run` no longer double-prints errors (REPORTED_ERROR dedup applied to headless).
|
|
30
|
+
|
|
31
|
+
### VS Code extension
|
|
32
|
+
- **Fixed the send dead-latch.** The chat panel set the tab's `isRunning` flag before reading it, so the direct-send branch was dead code: every message queued as "Queued: …" and was only released when an unrelated engine status event arrived.
|
|
33
|
+
- **Chat tabs stay in sync with the engine.** Switching tabs now posts `switchSession` (the host previously kept sending to the previous tab's session, and background-tab streams were dropped); `send`/`abort` honor the message's own `sessionId` with the host value as fallback. Queued messages now carry their images through the queue.
|
|
34
|
+
- **Feedback is actually stored.** The submit-feedback flow thanked the user and discarded everything; it now records through `FeedbackService` (local history, capped at 100 — nothing leaves the machine).
|
|
35
|
+
- **Tool-approval handler cleanup on dispose.** With a sidebar and an editor-tab chat open, closing the editor tab left its dead confirm handler registered, so in-panel approvals silently stopped for the surviving surface.
|
|
36
|
+
- `scalix.cliPath`, `scalix.baseUrl`, `scalix.openaiBaseUrl`, `scalix.provider`, and `scalix.permissionMode` are now machine-scoped — a repo's `.vscode/settings.json` can no longer point the engine spawn at arbitrary code or redirect the API key's destination endpoint.
|
|
37
|
+
|
|
38
|
+
### Known issues (from the 2026-10-14 audit, not yet addressed)
|
|
39
|
+
- The webview's per-tab `innerHTML` cache destroys event listeners on restored tabs (dead confirm-card buttons after a tab round-trip) — needs the data-driven re-render refactor.
|
|
40
|
+
- Background-tab stream events are still dropped rather than buffered per session.
|
|
41
|
+
- The OS command sandbox (seatbelt/bwrap) remains opt-in and default-off; the regex command blocklist is explicitly not a security boundary.
|
|
42
|
+
- DNS-rebinding TOCTOU remains in `web_fetch`/MCP fetch (validation and fetch resolve DNS independently).
|
|
43
|
+
- `vscode-extension/tests` are still not wired into either publish workflow.
|
|
44
|
+
|
|
5
45
|
## [1.5.1] - 2026-08-08
|
|
6
46
|
|
|
7
47
|
### Added
|
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ First launch opens a setup wizard to configure your Scalix API key, endpoint, an
|
|
|
99
99
|
| `SCALIX_MODEL` | Comma-separated model IDs |
|
|
100
100
|
| `SCALIX_BASE_URL` | API base URL |
|
|
101
101
|
| `SCALIX_PROJECT` | Project ID for org-wide keys (sent as `x-scalix-project` on platform tool calls) |
|
|
102
|
+
| `SCALIX_COMMAND_SANDBOX` | OS-level command sandbox: `off` \| `filesystem` \| `strict` (overrides config) |
|
|
102
103
|
| `BRAVE_API_KEY` | Brave Search API key |
|
|
103
104
|
| `TAVILY_API_KEY` | Tavily Search API key |
|
|
104
105
|
|
|
@@ -122,6 +123,7 @@ scalix run "generate a migration" --output migration.sql --format patch
|
|
|
122
123
|
| `/models` | List active models |
|
|
123
124
|
| `/model` | Switch model |
|
|
124
125
|
| `/mode` | Set permission mode |
|
|
126
|
+
| `/sandbox` | Set the OS command sandbox (off \| filesystem \| strict) |
|
|
125
127
|
| `/compact` | Compress context |
|
|
126
128
|
| `/cost` | Token usage & cost |
|
|
127
129
|
| `/budget` | Budget status |
|
|
@@ -224,7 +226,9 @@ scalix run "generate a migration" --output migration.sql --format patch
|
|
|
224
226
|
| `notebook_edit` | Jupyter notebook operations |
|
|
225
227
|
| `monitor` | Background process output |
|
|
226
228
|
| `agent` | Spawn sub-agents |
|
|
227
|
-
| `
|
|
229
|
+
| `write_artifact` | Write a reviewable plan / walkthrough / tasklist / summary to `.scalix/artifacts/` |
|
|
230
|
+
| `list_artifacts` | List previously written artifacts (newest first) |
|
|
231
|
+
| `add_mcp_server` | Register MCP servers |
|
|
228
232
|
|
|
229
233
|
## Configuration
|
|
230
234
|
|
|
@@ -255,6 +259,25 @@ Config is stored in `~/.scalix-coder/config.json`.
|
|
|
255
259
|
| `.scalix/rules/*.md` | Scoped rules |
|
|
256
260
|
| `mcp-services.json` | MCP server config |
|
|
257
261
|
|
|
262
|
+
### Command sandbox (opt-in)
|
|
263
|
+
|
|
264
|
+
`run_command` can execute inside a kernel-enforced sandbox — the OS-level wall
|
|
265
|
+
behind the logical checks (blocklist, injection detection, path containment).
|
|
266
|
+
Set `commandSandbox` in `~/.scalix-coder/config.json` (or `SCALIX_COMMAND_SANDBOX`):
|
|
267
|
+
|
|
268
|
+
| Mode | What it does |
|
|
269
|
+
| ------------ | ------------ |
|
|
270
|
+
| `off` | Default. No sandbox — behaviour unchanged. |
|
|
271
|
+
| `filesystem` | macOS seatbelt / Linux bubblewrap: writes confined to the workspace + system temp, reads unrestricted, network allowed. |
|
|
272
|
+
| `strict` | `filesystem` plus no network (unshared netns / seatbelt `deny network*`). For untrusted builds. |
|
|
273
|
+
|
|
274
|
+
Notes: Windows is not supported — with a mode set there, commands fail closed
|
|
275
|
+
with the reason instead of running unsandboxed. Package managers that cache in
|
|
276
|
+
`$HOME` (pip, cargo) will fail cache writes in `filesystem` mode; that is the
|
|
277
|
+
wall working. The backend binary must exist (`sandbox-exec` on macOS ships with
|
|
278
|
+
the OS; `bwrap` must be installed on Linux) — a missing backend is reported,
|
|
279
|
+
never skipped.
|
|
280
|
+
|
|
258
281
|
### MCP servers
|
|
259
282
|
|
|
260
283
|
Servers listed in `mcp-services.json` (project root, or `.scalix/` as a
|
|
@@ -284,6 +307,26 @@ is accepted too, so an existing config can be dropped in unchanged.
|
|
|
284
307
|
- MCP tools are external, so they require confirmation under the default
|
|
285
308
|
permission mode.
|
|
286
309
|
|
|
310
|
+
### Scalix Marketplace (`/marketplace`)
|
|
311
|
+
|
|
312
|
+
Browse the curated plugin catalog and install it in one command:
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
/marketplace featured listings
|
|
316
|
+
/marketplace <query> search the catalog
|
|
317
|
+
/marketplace info <id> detail + what an install would write
|
|
318
|
+
/marketplace install <id> install (stdio MCP needs `--confirm`)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Four artifact kinds are installed into this workspace: **MCP servers**
|
|
322
|
+
(`mcp-services.json`), **skills** (`.scalix/skills/<name>/`), **rules**
|
|
323
|
+
(`.scalix/rules/`) and **subagents** (`.scalix/agents/`). Every listing carries
|
|
324
|
+
a trust tier — ★ official, ✔ verified, · community — and an env-var reference
|
|
325
|
+
is stored by NAME only (you set the value in your environment). The catalog is
|
|
326
|
+
fetched from `https://api.scalix.world/v1/marketplace` (override with
|
|
327
|
+
`SCALIX_MARKETPLACE_URL`); payloads are re-validated before anything is
|
|
328
|
+
written to disk. Full contract: `scalix-cloud/docs/MARKETPLACE.md`.
|
|
329
|
+
|
|
287
330
|
## VS Code Extension
|
|
288
331
|
|
|
289
332
|
Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — or from [Open VSX](https://open-vsx.org/extension/scalix/scalix) if you use Cursor, Windsurf, or VSCodium:
|
package/dist/agent.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ApiError } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { getToolPermissionWithArgs, getMaxImageSize } from './sandbox.js';
|
|
4
|
+
import { getToolPermissionWithArgs, getMaxImageSize, sandboxPathAsync } from './sandbox.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
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';
|
|
@@ -12,6 +12,8 @@ import { AgentReplay } from './agent-replay.js';
|
|
|
12
12
|
import { loadHooks, runHooks } from './hooks.js';
|
|
13
13
|
import { isWorkspaceTrusted } from './workspace-trust.js';
|
|
14
14
|
import { setAgentToolConfig } from './tools-advanced.js';
|
|
15
|
+
import { setCommandSandboxMode } from './tools.js';
|
|
16
|
+
import { resolveCommandSandboxMode } from './command-sandbox.js';
|
|
15
17
|
import { AgentRegistry, generateAgentId } from './agent-registry.js';
|
|
16
18
|
import { getPreset } from './agent-presets.js';
|
|
17
19
|
import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
|
|
@@ -170,7 +172,13 @@ export class Agent extends EventEmitter {
|
|
|
170
172
|
configuredContextTokens;
|
|
171
173
|
toolSelector = null;
|
|
172
174
|
_aborted = false;
|
|
175
|
+
// Resolved when abort() fires — lets in-flight sleeps (retry backoff, tool
|
|
176
|
+
// confirmation) return immediately instead of ignoring cancellation.
|
|
177
|
+
_abortWaiters = new Set();
|
|
173
178
|
_thinkingActive = false;
|
|
179
|
+
// Reasoning text accumulated across the current turn (all model calls) —
|
|
180
|
+
// what "what is the agent thinking" renders from.
|
|
181
|
+
_reasoningText = '';
|
|
174
182
|
replay;
|
|
175
183
|
boardRefresher;
|
|
176
184
|
boardPrimed;
|
|
@@ -250,6 +258,11 @@ export class Agent extends EventEmitter {
|
|
|
250
258
|
workspaceRoot: config.workspaceRoot ?? process.cwd(),
|
|
251
259
|
};
|
|
252
260
|
this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
|
|
261
|
+
// OS-level command sandbox: the tool module is process-global, so every
|
|
262
|
+
// front end (CLI, IPC sessions, headless) inherits the mode the moment an
|
|
263
|
+
// agent is constructed. SCALIX_COMMAND_SANDBOX overrides the config value.
|
|
264
|
+
// Per-session override is deliberate — this is machine policy, not chat state.
|
|
265
|
+
setCommandSandboxMode(resolveCommandSandboxMode(config.commandSandbox, process.env.SCALIX_COMMAND_SANDBOX));
|
|
253
266
|
this.contextManager = new ContextManager(config.contextManager);
|
|
254
267
|
// Kept separately because syncContextLimit() overwrites the manager's own
|
|
255
268
|
// value on every read and would otherwise have nothing to fall back to.
|
|
@@ -451,8 +464,14 @@ export class Agent extends EventEmitter {
|
|
|
451
464
|
this.messages = [];
|
|
452
465
|
this.verifyFailCounts.clear();
|
|
453
466
|
this.modelFailures.clear();
|
|
467
|
+
// The old index points into the discarded history — keeping it would make
|
|
468
|
+
// the next addOutputFormatInstructions() overwrite an unrelated message.
|
|
469
|
+
this.outputFormatInstructionIndex = null;
|
|
470
|
+
}
|
|
471
|
+
loadMessages(messages) {
|
|
472
|
+
this.messages = [...messages];
|
|
473
|
+
this.outputFormatInstructionIndex = null;
|
|
454
474
|
}
|
|
455
|
-
loadMessages(messages) { this.messages = [...messages]; }
|
|
456
475
|
getInstructions() { return this.config.instructions; }
|
|
457
476
|
setInstructions(instructions) { this.config.instructions = instructions; }
|
|
458
477
|
getPermissionMode() { return this.config.permissionMode; }
|
|
@@ -900,6 +919,12 @@ export class Agent extends EventEmitter {
|
|
|
900
919
|
return {
|
|
901
920
|
apiKey: this.config.apiKey,
|
|
902
921
|
baseUrl: this.client.baseURL,
|
|
922
|
+
// Sub-agents must inherit the provider itself, not just its URL: without
|
|
923
|
+
// it a BYOK session's sub-agent would default to provider 'scalix' while
|
|
924
|
+
// keeping the third-party baseUrl and 404 on <byok-host>/v1/ai/...
|
|
925
|
+
provider: this.providerId,
|
|
926
|
+
cloudApiKey: this.cloudApiKey,
|
|
927
|
+
cloudBaseUrl: this.cloudBaseUrl,
|
|
903
928
|
models: this.config.models,
|
|
904
929
|
permissionMode: this.config.permissionMode,
|
|
905
930
|
confirmToolCall: this.config.confirmToolCall,
|
|
@@ -911,8 +936,26 @@ export class Agent extends EventEmitter {
|
|
|
911
936
|
this._abortController.abort();
|
|
912
937
|
this._abortController = null;
|
|
913
938
|
}
|
|
939
|
+
const waiters = [...this._abortWaiters];
|
|
940
|
+
this._abortWaiters.clear();
|
|
941
|
+
for (const w of waiters)
|
|
942
|
+
w();
|
|
914
943
|
this.emitThinkingEnd();
|
|
915
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* Sleep that returns early when abort() fires. Unlike a bare setTimeout,
|
|
947
|
+
* cancellation during retry backoff or a confirmation wait is observed
|
|
948
|
+
* immediately rather than after the full delay.
|
|
949
|
+
*/
|
|
950
|
+
abortableSleep(ms) {
|
|
951
|
+
if (this._aborted)
|
|
952
|
+
return Promise.resolve();
|
|
953
|
+
return new Promise((resolve) => {
|
|
954
|
+
const waiter = () => { clearTimeout(timer); resolve(); };
|
|
955
|
+
const timer = setTimeout(() => { this._abortWaiters.delete(waiter); resolve(); }, ms);
|
|
956
|
+
this._abortWaiters.add(waiter);
|
|
957
|
+
});
|
|
958
|
+
}
|
|
916
959
|
removeModel(model) {
|
|
917
960
|
const idx = this.config.models.indexOf(model);
|
|
918
961
|
if (idx !== -1 && this.config.models.length > 1) {
|
|
@@ -935,7 +978,11 @@ export class Agent extends EventEmitter {
|
|
|
935
978
|
const imageParts = [];
|
|
936
979
|
for (const filePath of matches) {
|
|
937
980
|
try {
|
|
938
|
-
|
|
981
|
+
// @file mentions expand file contents into the prompt — route them
|
|
982
|
+
// through the same containment as read_file, and resolve against the
|
|
983
|
+
// configured workspace root (not necessarily process.cwd()).
|
|
984
|
+
const root = this.config.workspaceRoot ?? process.cwd();
|
|
985
|
+
const resolved = await sandboxPathAsync(path.resolve(root, filePath));
|
|
939
986
|
if (isImageFile(filePath)) {
|
|
940
987
|
const dataUrl = await encodeImageToDataUrl(resolved);
|
|
941
988
|
imageParts.push({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } });
|
|
@@ -1051,6 +1098,7 @@ export class Agent extends EventEmitter {
|
|
|
1051
1098
|
this.messages.push(userMessage);
|
|
1052
1099
|
this.emit('message:user', userMessage);
|
|
1053
1100
|
this._thinkingActive = true;
|
|
1101
|
+
this._reasoningText = '';
|
|
1054
1102
|
this.emit('thinking:start');
|
|
1055
1103
|
this.truncateMessages();
|
|
1056
1104
|
this.replay.record('user_message', { content });
|
|
@@ -1137,6 +1185,15 @@ export class Agent extends EventEmitter {
|
|
|
1137
1185
|
}
|
|
1138
1186
|
this.checkInbox();
|
|
1139
1187
|
turnCount++;
|
|
1188
|
+
// Per-turn metrics reset here so streaming TTFT (and token/duration
|
|
1189
|
+
// counters) reflect the turn that actually produced the final response,
|
|
1190
|
+
// not the first turn of the request.
|
|
1191
|
+
_turnToolDurationMs = 0;
|
|
1192
|
+
_turnLlmStartMs = 0;
|
|
1193
|
+
_turnTtftMs = 0;
|
|
1194
|
+
_firstDeltaSeen = false;
|
|
1195
|
+
_turnInputTokens = 0;
|
|
1196
|
+
_turnOutputTokens = 0;
|
|
1140
1197
|
let fullText = '';
|
|
1141
1198
|
const toolCalls = new Map();
|
|
1142
1199
|
try {
|
|
@@ -1211,8 +1268,10 @@ export class Agent extends EventEmitter {
|
|
|
1211
1268
|
// An endpoint that does not carry tool calls over SSE would answer an
|
|
1212
1269
|
// agentic turn in prose and lose the calls, so those turns go
|
|
1213
1270
|
// non-streaming there and the user watches a spinner instead of output.
|
|
1214
|
-
// Text-only turns stream either way.
|
|
1215
|
-
|
|
1271
|
+
// Text-only turns stream either way. The probe must never fail a turn:
|
|
1272
|
+
// any error defaults to the conservative non-streaming answer.
|
|
1273
|
+
const streams = await this.streamsToolCalls().catch(() => false);
|
|
1274
|
+
const useStreaming = !forceNonStreaming && (streams
|
|
1216
1275
|
? true
|
|
1217
1276
|
: (!apiTools || apiTools.length === 0) && !hasToolMessages);
|
|
1218
1277
|
if (useStreaming) {
|
|
@@ -1243,6 +1302,20 @@ export class Agent extends EventEmitter {
|
|
|
1243
1302
|
fullText += delta.content;
|
|
1244
1303
|
this.emit('stream:delta', delta.content, fullText);
|
|
1245
1304
|
}
|
|
1305
|
+
// Reasoning deltas (deepseek-reasoner, glm thinking models,
|
|
1306
|
+
// OpenRouter :thinking, the Scalix router's thinking_level) were
|
|
1307
|
+
// dropped — the "what is the agent thinking" display streams
|
|
1308
|
+
// from these. Some models emit reasoning unconditionally; when
|
|
1309
|
+
// it arrives after content started, re-open the thinking phase.
|
|
1310
|
+
const reasoningPiece = delta.reasoning_content ?? delta.reasoning ?? null;
|
|
1311
|
+
if (reasoningPiece) {
|
|
1312
|
+
if (!this._thinkingActive) {
|
|
1313
|
+
this._thinkingActive = true;
|
|
1314
|
+
this.emit('thinking:start');
|
|
1315
|
+
}
|
|
1316
|
+
this._reasoningText += reasoningPiece;
|
|
1317
|
+
this.emit('reasoning:update', this._reasoningText);
|
|
1318
|
+
}
|
|
1246
1319
|
if (delta.tool_calls) {
|
|
1247
1320
|
for (const tc of delta.tool_calls) {
|
|
1248
1321
|
const idx = tc.index ?? toolCalls.size;
|
|
@@ -1299,6 +1372,12 @@ export class Agent extends EventEmitter {
|
|
|
1299
1372
|
fullText = choice.message.content;
|
|
1300
1373
|
this.emit('stream:delta', fullText, fullText);
|
|
1301
1374
|
}
|
|
1375
|
+
// Non-streaming path carries reasoning the same way.
|
|
1376
|
+
const msg = choice.message;
|
|
1377
|
+
if (msg.reasoning || msg.reasoning_content) {
|
|
1378
|
+
this._reasoningText += String(msg.reasoning_content ?? msg.reasoning ?? '');
|
|
1379
|
+
this.emit('reasoning:update', this._reasoningText);
|
|
1380
|
+
}
|
|
1302
1381
|
if (choice.message.tool_calls) {
|
|
1303
1382
|
for (const tc of choice.message.tool_calls) {
|
|
1304
1383
|
toolCalls.set(toolCalls.size, {
|
|
@@ -1381,10 +1460,16 @@ export class Agent extends EventEmitter {
|
|
|
1381
1460
|
this.messages.push(assistantMessage);
|
|
1382
1461
|
this.emit('message:assistant', assistantMessage);
|
|
1383
1462
|
if (resolvedToolCalls.length > 0) {
|
|
1384
|
-
|
|
1463
|
+
// Multiple sub-agent spawns in one turn run concurrently — the
|
|
1464
|
+
// Kilo Code behavior for parallel Task calls. Every spawn is an
|
|
1465
|
+
// independent `agent` call, so sequencing them only adds wall-clock
|
|
1466
|
+
// time; any other tool mix stays sequential because write ordering
|
|
1467
|
+
// matters. Each call still goes through the same permission check,
|
|
1468
|
+
// confirmation, hooks and result plumbing as a solo call.
|
|
1469
|
+
const runCall = async (call) => {
|
|
1385
1470
|
if (this._aborted) {
|
|
1386
1471
|
reqLog.info('Agent aborted, skipping remaining tool calls');
|
|
1387
|
-
|
|
1472
|
+
return;
|
|
1388
1473
|
}
|
|
1389
1474
|
const tool = this.config.tools.find((t) => (t.function?.name || t.name) === call.name);
|
|
1390
1475
|
let result;
|
|
@@ -1399,7 +1484,7 @@ export class Agent extends EventEmitter {
|
|
|
1399
1484
|
this.emit('tool:blocked', call.name, 'Blocked by hook');
|
|
1400
1485
|
this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
|
|
1401
1486
|
this.emit('tool:result', call.name, result);
|
|
1402
|
-
|
|
1487
|
+
return;
|
|
1403
1488
|
}
|
|
1404
1489
|
}
|
|
1405
1490
|
// Parse tool arguments once, up front, so the permission decision,
|
|
@@ -1419,7 +1504,7 @@ export class Agent extends EventEmitter {
|
|
|
1419
1504
|
this.emit('tool:blocked', call.name, 'Malformed arguments');
|
|
1420
1505
|
this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
|
|
1421
1506
|
this.emit('tool:result', call.name, result);
|
|
1422
|
-
|
|
1507
|
+
return;
|
|
1423
1508
|
}
|
|
1424
1509
|
}
|
|
1425
1510
|
// Args-aware permission so scoped allow/deny rules are enforced.
|
|
@@ -1435,7 +1520,7 @@ export class Agent extends EventEmitter {
|
|
|
1435
1520
|
name: call.name,
|
|
1436
1521
|
});
|
|
1437
1522
|
this.emit('tool:result', call.name, result);
|
|
1438
|
-
|
|
1523
|
+
return;
|
|
1439
1524
|
}
|
|
1440
1525
|
if (permission === 'ask') {
|
|
1441
1526
|
if (!this.config.confirmToolCall) {
|
|
@@ -1453,24 +1538,36 @@ export class Agent extends EventEmitter {
|
|
|
1453
1538
|
name: call.name,
|
|
1454
1539
|
});
|
|
1455
1540
|
this.emit('tool:result', call.name, result);
|
|
1456
|
-
|
|
1541
|
+
return;
|
|
1457
1542
|
}
|
|
1458
1543
|
}
|
|
1459
1544
|
else {
|
|
1460
1545
|
const args = parsedArgs;
|
|
1461
1546
|
const CONFIRM_TIMEOUT_MS = 300_000;
|
|
1462
1547
|
const confirmed = await new Promise((resolve) => {
|
|
1463
|
-
|
|
1464
|
-
|
|
1548
|
+
let settled = false;
|
|
1549
|
+
const done = (v) => {
|
|
1550
|
+
if (settled)
|
|
1551
|
+
return;
|
|
1552
|
+
settled = true;
|
|
1465
1553
|
clearTimeout(timer);
|
|
1554
|
+
this._abortWaiters.delete(waiter);
|
|
1466
1555
|
resolve(v);
|
|
1556
|
+
};
|
|
1557
|
+
// An abort during the wait denies the tool immediately; the
|
|
1558
|
+
// loop's `if (this._aborted) break` below then exits —
|
|
1559
|
+
// otherwise the loop parks here for up to 5 minutes.
|
|
1560
|
+
const waiter = () => done(false);
|
|
1561
|
+
const timer = setTimeout(() => done(false), CONFIRM_TIMEOUT_MS);
|
|
1562
|
+
this._abortWaiters.add(waiter);
|
|
1563
|
+
this.config.confirmToolCall(call.name, args).then((v) => {
|
|
1564
|
+
done(v);
|
|
1467
1565
|
}).catch(() => {
|
|
1468
|
-
|
|
1469
|
-
resolve(false);
|
|
1566
|
+
done(false);
|
|
1470
1567
|
});
|
|
1471
1568
|
});
|
|
1472
1569
|
if (this._aborted)
|
|
1473
|
-
|
|
1570
|
+
return;
|
|
1474
1571
|
if (!confirmed) {
|
|
1475
1572
|
result = { denied: true, message: `User denied execution of ${call.name}` };
|
|
1476
1573
|
reqLog.info('Tool call denied by user', { tool: call.name });
|
|
@@ -1482,7 +1579,7 @@ export class Agent extends EventEmitter {
|
|
|
1482
1579
|
name: call.name,
|
|
1483
1580
|
});
|
|
1484
1581
|
this.emit('tool:result', call.name, result);
|
|
1485
|
-
|
|
1582
|
+
return;
|
|
1486
1583
|
}
|
|
1487
1584
|
}
|
|
1488
1585
|
}
|
|
@@ -1529,13 +1626,7 @@ export class Agent extends EventEmitter {
|
|
|
1529
1626
|
workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
|
|
1530
1627
|
permissionMode: this.config.permissionMode,
|
|
1531
1628
|
agentId: this.agentId,
|
|
1532
|
-
agentConfig:
|
|
1533
|
-
apiKey: this.config.apiKey,
|
|
1534
|
-
baseUrl: this.client.baseURL,
|
|
1535
|
-
models: this.config.models,
|
|
1536
|
-
permissionMode: this.config.permissionMode,
|
|
1537
|
-
confirmToolCall: this.config.confirmToolCall,
|
|
1538
|
-
},
|
|
1629
|
+
agentConfig: this.getAgentConfig(),
|
|
1539
1630
|
}), toolTimeout, call.name);
|
|
1540
1631
|
const toolDurationMs = Date.now() - toolStart;
|
|
1541
1632
|
_turnToolDurationMs += toolDurationMs;
|
|
@@ -1675,6 +1766,20 @@ export class Agent extends EventEmitter {
|
|
|
1675
1766
|
}
|
|
1676
1767
|
}
|
|
1677
1768
|
}
|
|
1769
|
+
};
|
|
1770
|
+
if (resolvedToolCalls.length > 1 && resolvedToolCalls.every((c) => c.name === 'agent')) {
|
|
1771
|
+
await Promise.all(resolvedToolCalls.map((c) => runCall(c).catch((err) => {
|
|
1772
|
+
reqLog.error('Sub-agent spawn failed', { tool: c.name, error: err?.message });
|
|
1773
|
+
})));
|
|
1774
|
+
}
|
|
1775
|
+
else {
|
|
1776
|
+
for (const call of resolvedToolCalls) {
|
|
1777
|
+
if (this._aborted) {
|
|
1778
|
+
reqLog.info('Agent aborted, skipping remaining tool calls');
|
|
1779
|
+
break;
|
|
1780
|
+
}
|
|
1781
|
+
await runCall(call);
|
|
1782
|
+
}
|
|
1678
1783
|
}
|
|
1679
1784
|
if (this._aborted)
|
|
1680
1785
|
return '[interrupted]';
|
|
@@ -1721,6 +1826,15 @@ export class Agent extends EventEmitter {
|
|
|
1721
1826
|
}
|
|
1722
1827
|
catch (err) {
|
|
1723
1828
|
this._abortController = null;
|
|
1829
|
+
// A user-initiated abort is not a model failure — recording it would
|
|
1830
|
+
// open the circuit breaker after a few Escapes and silently rotate
|
|
1831
|
+
// the user's model. An abort mid-request mirrors the between-iteration
|
|
1832
|
+
// contract: deregister and return '[interrupted]'.
|
|
1833
|
+
if (this._aborted || err?.name === 'AbortError') {
|
|
1834
|
+
reqLog.info('Agent aborted during request');
|
|
1835
|
+
this.deregisterFromRegistry('aborted');
|
|
1836
|
+
return '[interrupted]';
|
|
1837
|
+
}
|
|
1724
1838
|
this.recordModelFailure(currentModel);
|
|
1725
1839
|
const errMsg = (err?.message || '').toLowerCase();
|
|
1726
1840
|
const isStreamError = errMsg.includes('stream') || errMsg.includes('sse') || errMsg.includes('no response body');
|
|
@@ -1728,7 +1842,7 @@ export class Agent extends EventEmitter {
|
|
|
1728
1842
|
if (isStreamError && !forceNonStreaming) {
|
|
1729
1843
|
forceNonStreaming = true;
|
|
1730
1844
|
reqLog.warn('Streaming failed, retrying with non-streaming mode', { error: err.message });
|
|
1731
|
-
await
|
|
1845
|
+
await this.abortableSleep(500);
|
|
1732
1846
|
continue;
|
|
1733
1847
|
}
|
|
1734
1848
|
if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
|
|
@@ -1749,7 +1863,7 @@ export class Agent extends EventEmitter {
|
|
|
1749
1863
|
status: err instanceof ApiError ? err.status : undefined,
|
|
1750
1864
|
reason: err.message,
|
|
1751
1865
|
});
|
|
1752
|
-
await
|
|
1866
|
+
await this.abortableSleep(delay);
|
|
1753
1867
|
currentModel = this.pickModel();
|
|
1754
1868
|
continue;
|
|
1755
1869
|
}
|
package/dist/api-client.js
CHANGED
|
@@ -12,6 +12,62 @@ export class ApiError extends Error {
|
|
|
12
12
|
this.retryAfterMs = retryAfterMs;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
// ─── Request body guards ───
|
|
16
|
+
// Two confirmed ways a request dies with the gateway's generic
|
|
17
|
+
// "invalid JSON body" (400) before the model ever sees it:
|
|
18
|
+
//
|
|
19
|
+
// 1. A body over the wall. The gateway and the proxies in front of it
|
|
20
|
+
// refuse bodies past ~200KB — measured 192KB passing and 256KB
|
|
21
|
+
// failing — and the refusal is "invalid JSON body", not 413, which
|
|
22
|
+
// reads like a truncated stream. A single tool result is allowed to
|
|
23
|
+
// reach 500K chars, so one fat command output is enough to cross it.
|
|
24
|
+
//
|
|
25
|
+
// 2. A lone surrogate escape. JSON.stringify serializes half of a cut
|
|
26
|
+
// surrogate pair as \ud83d. JavaScript's JSON.parse accepts that;
|
|
27
|
+
// strict parsers (serde_json, Go's encoding/json) reject it. undici
|
|
28
|
+
// would silently map it to U+FFFD on the wire anyway, so the model
|
|
29
|
+
// never saw the half a character either.
|
|
30
|
+
//
|
|
31
|
+
// Both are fixed here rather than at every truncation site: the body
|
|
32
|
+
// is sanitized, and tool results are shrunk until the request fits.
|
|
33
|
+
const MAX_BODY_BYTES = 180_000;
|
|
34
|
+
function utf8ByteLength(s) {
|
|
35
|
+
if (typeof Buffer !== 'undefined')
|
|
36
|
+
return Buffer.byteLength(s, 'utf8');
|
|
37
|
+
return new TextEncoder().encode(s).length;
|
|
38
|
+
}
|
|
39
|
+
function sanitizeLoneSurrogates(body) {
|
|
40
|
+
return body.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
|
|
41
|
+
}
|
|
42
|
+
/** Cut tool-result contents, largest first, until the serialized
|
|
43
|
+
* request fits. Tool results are the bulk of an oversized request,
|
|
44
|
+
* and trimming their text keeps tool_call/result pairing intact —
|
|
45
|
+
* dropping whole messages would break the pairing the wire requires.
|
|
46
|
+
* Rounds, not one pass: a single command output can be many times
|
|
47
|
+
* the wall, so the largest result is re-halved until the check
|
|
48
|
+
* converges. */
|
|
49
|
+
function shrinkToFit(req, maxBytes) {
|
|
50
|
+
let out = req;
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (utf8ByteLength(JSON.stringify(out)) <= maxBytes)
|
|
53
|
+
return out;
|
|
54
|
+
const biggest = out.messages
|
|
55
|
+
.map((m, i) => ({ m, i }))
|
|
56
|
+
.filter(({ m }) => m.role === 'tool' && typeof m.content === 'string' && m.content.length > 400)
|
|
57
|
+
.sort((a, b) => b.m.content.length - a.m.content.length)[0];
|
|
58
|
+
// Nothing left worth cutting — the request is over for a reason
|
|
59
|
+
// this pass cannot fix, so send what remains.
|
|
60
|
+
if (!biggest)
|
|
61
|
+
return out;
|
|
62
|
+
const text = biggest.m.content;
|
|
63
|
+
const messages = [...out.messages];
|
|
64
|
+
messages[biggest.i] = {
|
|
65
|
+
...biggest.m,
|
|
66
|
+
content: text.slice(0, Math.max(200, Math.floor(text.length / 2))) + '\n...[trimmed so the request fits the endpoint]',
|
|
67
|
+
};
|
|
68
|
+
out = { ...out, messages };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
15
71
|
/**
|
|
16
72
|
* Parse a Retry-After header value into milliseconds. Accepts both forms from
|
|
17
73
|
* RFC 9110: delta-seconds ("120") and HTTP-date. Returns undefined for absent
|
|
@@ -31,11 +87,13 @@ export function parseRetryAfter(value) {
|
|
|
31
87
|
// Per-request abort. 300s matches the platform's own proxy walls; the old
|
|
32
88
|
// 120s default aborted the client before a slow first token could arrive on
|
|
33
89
|
// CPU-served models (the request was fine, we hung up on it). For reference,
|
|
34
|
-
// the OpenAI and Anthropic SDKs both default to 600s.
|
|
90
|
+
// the OpenAI and Anthropic SDKs both default to 600s. The default applies
|
|
91
|
+
// when SCALIX_REQUEST_TIMEOUT_MS is unset — 0 would mean "no timeout", which
|
|
92
|
+
// lets a wedged connection hang the turn forever.
|
|
35
93
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
36
94
|
const v = process.env.SCALIX_REQUEST_TIMEOUT_MS;
|
|
37
95
|
const n = v ? parseInt(v, 10) : NaN;
|
|
38
|
-
return Number.isFinite(n) ? n :
|
|
96
|
+
return Number.isFinite(n) && n > 0 ? n : 300_000;
|
|
39
97
|
})();
|
|
40
98
|
// The capability probe only decides whether a turn streams. It must never be
|
|
41
99
|
// the reason a turn is slow to start, so it gives up quickly and falls back to
|
|
@@ -123,7 +181,8 @@ export class ScalixClient {
|
|
|
123
181
|
// as many times as there are narrowings left.
|
|
124
182
|
for (;;) {
|
|
125
183
|
const prepared = this.prepareRequest(req);
|
|
126
|
-
const
|
|
184
|
+
const wire = shrinkToFit(prepared, MAX_BODY_BYTES);
|
|
185
|
+
const body = sanitizeLoneSurrogates(JSON.stringify(wire));
|
|
127
186
|
const timeoutSignal = this.timeoutMs > 0 ? AbortSignal.timeout(this.timeoutMs) : undefined;
|
|
128
187
|
const combined = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal || timeoutSignal);
|
|
129
188
|
const response = await fetch(url, {
|