scalix 1.4.0 → 1.5.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 CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to Scalix Coder will be documented in this file.
4
4
 
5
+ ## [1.5.0] - 2026-08-02
6
+
7
+ ### Added
8
+ - **BYOK — bring your own key and models.** New provider layer (`src/providers/`): the agent now runs against any **OpenAI-compatible endpoint** (OpenAI, OpenRouter, Ollama, LM Studio, Together, vLLM, LiteLLM, ...) with your own key. Setup wizard gained a provider step (Scalix stays the zero-config default/recommended); env config via `SCALIX_PROVIDER=openai` + `OPENAI_BASE_URL` + `OPENAI_API_KEY`. Endpoint roots are accepted with or without `/v1`. No default third-party endpoint — BYOK is fully opt-in.
9
+ - Provider-scoped behavior: BYOK endpoints stream every turn (native tool-call deltas), skip the Scalix-only `max_tokens` clamp, and never receive the Scalix `thinking_level` extension; Scalix Cloud platform tools are gated to the Scalix provider. Per-provider credential slots in the OS keychain; legacy `/v1`-suffixed Scalix bases keep working.
10
+ - BYOK model discovery reads whichever context-window field the endpoint actually reports (`context_length`, `max_model_len`, `max_context_length`, `context_window`, top level or under `top_provider`). A model that reports none is left without one rather than inheriting a borrowed number, and the wizard pre-fills a conservative 128K instead of a 1M figure almost no third-party model can honour.
11
+ - BYOK streams ask for `stream_options.include_usage`, so `/cost`, the status bar and the context meter carry real numbers instead of sitting on zero. Dropped for the session if the endpoint rejects the field.
12
+ - **Real cost tracking** — non-streaming responses read the platform's `x-scalix-cost-cents` estimate into `usage.cost`; `/cost` shows it as "Est. cost" with a pointer to console billing. No client-side price tables, nothing fabricated.
13
+ - 429/503 responses honor the server's `Retry-After` (capped at 60s) instead of blind exponential backoff, and the wait is now visible while it happens instead of the turn sitting silently on "Thinking...".
14
+ - `/status` shows the active provider; provider selection round-trips through config validation (whitelisted).
15
+ - A benchmark suite (`bench/`, repo only — not in the package): seven coding tasks graded by held-out verifiers that never exist in the workspace the agent sees, with protected paths and mutation-graded test writing. Runs against `bin/scalix.js`, the same entrypoint npm installs. `npm run bench` for the deterministic tier, `npm run bench:live` against a real model.
16
+
17
+ ### Fixed
18
+ - **Responses no longer truncate mid-answer** — every request on the Scalix path sends an explicit `max_tokens`, and the ceiling is now 16,384 (matching the platform's own cap) rather than 4,000. Omitting the field lets the platform apply a much smaller default. The ceiling is per session: the client asks for 16,384 and falls back to 4,000 for the rest of the session if the endpoint rejects it, so older deployments keep working without configuration.
19
+ - **Tool calling against the Scalix platform** — agentic turns are no longer forced non-streaming. The client asks `/v1/ai/models` what the deployment supports and streams tool calls only where `streaming_tool_calls` is advertised; anything older, or unreachable, falls back to the non-streaming path that returns tool calls intact.
20
+ - The Scalix identity probe (`/v1/me`) is no longer sent on BYOK sessions. It was posting your provider key to a route that does not exist on OpenRouter/Ollama/vLLM and turning the resulting 404 into a "Scalix Cloud unavailable" warning on a session that never involved Scalix Cloud.
21
+ - Keychain reads and writes time out instead of hanging. A macOS keychain prompt nobody can see used to block the setup wizard's final keypress for ~20s with no feedback. When the keychain refuses outright and a secret has to stay in `config.json`, the app now says so instead of failing silently.
22
+ - Token and cost reporting: totals are derived when a server sends only prompt/completion halves; a resumed session no longer prints "12,000 (0 prompt + 0 completion)"; and a session that spent tokens but received no cost header says where the real figure lives instead of implying it was free.
23
+ - Terminal rendering: headings no longer arrive with their literal `##` attached, and emphasis inside list items is styled rather than printed as raw asterisks. Tool results are summarised to a readable line instead of a raw JSON envelope that buried the output and truncated the exit code. The banner is dropped rather than sliced in short or narrow terminals.
24
+ - `/help` and the `/` typeahead are generated from one catalog and can no longer disagree — `/update` was reachable but unlisted, while `/fork`, `/permissions`, `/resume` and `/skills` were listed but missing from `/help`.
25
+ - The setup wizard no longer opens its model step showing "At least one model is required" before anything has been typed.
26
+ - Replay files are written with owner-only permissions (0600), matching sessions/config.
27
+
28
+ ### Fixed (VS Code extension)
29
+ - A tool that failed is shown as failed. Every tool reports failure the same way and the panel was marking them all with a success check, on live and replayed sessions alike.
30
+ - A model spec entered in the panel now reaches the engine: it is written to the shared config and pushed into the running process, where previously it affected no request at all.
31
+ - Responses can no longer be lost between the final stream delta and the end of a turn, which left an empty reply bubble on tool-heavy turns.
32
+ - Deleting a session asks first; the connection indicator can no longer read green while the engine is down; the thinking indicator stops when a turn ends without text; and the send button announces what it actually does when it becomes an abort button.
33
+ - Keyboard shortcut help is generated from the extension manifest, so it cannot drift from the real bindings. `Scalix Coder: Open` renamed to `Scalix Coder: Open Chat Panel`.
34
+
35
+ ### Changed
36
+ - System prompt's Scalix Platform section corrected to real products and behavior: Scalix Run (via `scalix-cloud` CLI) for deploys, ScalixNova for databases, Scalix Sandboxes for code execution, exact `scalix-lumio-*` model ids, no "automatic model routing" claim, and an explicit provider-non-disclosure rule.
37
+ - Legacy public model ids in saved configs (`scalix-lite`, `scalix-balanced`, `scalix-max`, `scalix-embed`, `scalix-world-ai`, `scalix-advanced`) are normalized to their current Lumio ids on load.
38
+ - Chat completions now send `x-client-type: cli` (parity with the models/cloud endpoints).
39
+ - IPC `ready` event includes the engine `version` so the VS Code extension can detect engine/extension drift.
40
+
5
41
  ## [1.4.0] - 2026-07-05
6
42
 
7
43
  ### Added
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Scalix Coder
2
2
 
3
- AI-powered coding agent for the terminal and VS Code — built by [Scalix World](https://scalix.world).
3
+ AI-powered coding agent for the terminal and [VS Code](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) — built by [Scalix World](https://scalix.world).
4
4
 
5
5
  ![Version](https://img.shields.io/badge/version-1.4.0-blue)
6
6
  ![Node](https://img.shields.io/badge/node-%3E%3D18-green)
7
7
  ![License](https://img.shields.io/badge/license-EULA-orange)
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 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.
9
+ Scalix Coder is a full-featured AI coding assistant that runs in your terminal and in your editor — the extension is live on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=scalix.scalix) and [Open VSX](https://open-vsx.org/extension/scalix/scalix) (Cursor/Windsurf/VSCodium). It connects to the Scalix API (or your own compatible endpoint) and gives AI direct access to your codebase through 40 built-in tools — file editing, shell execution, search, git, web access, browser automation, vision, voice input, and more.
10
10
 
11
11
  ## One agent for your whole cloud
12
12
 
@@ -16,15 +16,34 @@ With a Scalix account, the agent is more than a coder: on start it fetches your
16
16
  - `/cloud` shows your connection, plan, and available tools; `/cloud refresh` re-fetches the catalog
17
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
18
18
 
19
+ ## Bring your own key (BYOK)
20
+
21
+ Scalix is the zero-config default, but the agent also runs against **any OpenAI-compatible endpoint** with your own key — OpenAI, OpenRouter, Ollama, LM Studio, Together, vLLM, LiteLLM, and friends. Pick "OpenAI-compatible endpoint" in the setup wizard (`scalix` → provider step, or `/setup`), or configure via env:
22
+
23
+ ```bash
24
+ export SCALIX_PROVIDER=openai
25
+ export OPENAI_BASE_URL=http://localhost:11434 # with or without /v1
26
+ export OPENAI_API_KEY=sk-...
27
+ export SCALIX_MODEL=llama3
28
+ scalix
29
+ ```
30
+
31
+ Notes:
32
+ - There is **no default third-party endpoint** — BYOK is fully opt-in and the endpoint URL is always yours.
33
+ - Scalix Cloud platform tools (database, deploys, storage, ...) are Scalix-only and are skipped on BYOK providers.
34
+ - On BYOK endpoints the agent streams every turn (tool calls included) and lets the server govern output limits; model context/output specs come from your `customModels` config when you want exact windows.
35
+ - Credentials are stored per provider (OS keychain / 0600 config), never shared between providers.
36
+ - Anthropic, Vertex, and Bedrock native adapters are specced follow-ups (see MULTI-PROVIDER-ADAPTER-SPEC.md).
37
+
19
38
  ## Features
20
39
 
21
40
  - **Terminal UI** — Rich React/Ink interface with magenta-themed prompt, scrollable chat, streaming responses, and keyboard shortcuts
22
- - **VS Code Extension** — Sidebar chat panel with inline diffs, tab completion, inline chat (Cmd+I), codebase search, model picker, checkpoints, and session history
23
- - **31 Built-in Tools** — Shell, file read/write/edit, search (ripgrep), git, web fetch/search, directory tree, notebooks, browser automation, vision, voice input, sub-agents
41
+ - **[VS Code Extension](https://marketplace.visualstudio.com/items?itemName=scalix.scalix)** — Sidebar chat panel with inline diffs, tab completion, inline chat (Cmd+I), codebase search, model picker, checkpoints, and session history
42
+ - **40 Built-in Tools** — Shell, file read/write/edit, search (ripgrep), git, web fetch/search, directory tree, notebooks, browser automation, vision, voice input, sub-agents, task board
24
43
  - **Security Sandbox** — Path traversal prevention, symlink detection, command blocklist, injection detection, SSRF protection
25
44
  - **4 Permission Modes** — `plan` (read-only) / `default` (ask) / `auto` (auto-approve) / `yolo` (full autonomy)
26
45
  - **Context Management** — Auto-compression, sliding window, per-model context limits
27
- - **Dynamic Model Registry** — Models fetched from your Scalix account with exact context windows
46
+ - **Dynamic Model Registry** — Models fetched from your Scalix account; context windows applied when the platform supplies them (sensible defaults otherwise, or set exact limits per model in config)
28
47
  - **Session Persistence** — Save, load, and resume conversations
29
48
  - **Project Intelligence** — Auto-detects tech stack, conventions, git context
30
49
  - **Plugin System** — Custom tools via `.scalix/plugins/`
@@ -199,14 +218,14 @@ Config is stored in `~/.scalix-coder/config.json`.
199
218
 
200
219
  | Setting | Default | Description |
201
220
  | ---------------------- | --------- | --------------------------------------------- |
202
- | `maxSteps` | 200 | Tool-call rounds per request |
203
- | `maxMessages` | 200 | History size |
221
+ | `maxSteps` | 500 | Tool-call rounds per request |
222
+ | `maxMessages` | 500 | History size |
204
223
  | `maxContextTokens` | 1,000,000 | Context window (tokens) |
205
- | `compressionThreshold` | 0.75 | Auto-compress when context exceeds this ratio |
206
- | `maxToolResultTokens` | 10,000 | Truncate tool results (tokens) |
207
- | `commandTimeoutMs` | 60,000 | Shell command timeout (ms) |
208
- | `bgCleanupMs` | 300,000 | Background process auto-cleanup (5 min) |
209
- | `readFileMaxSize` | 10 MB | Max file size for read operations |
224
+ | `compressionThreshold` | 0.80 | Auto-compress when context exceeds this ratio |
225
+ | `maxToolResultTokens` | 50,000 | Truncate tool results (tokens) |
226
+ | `commandTimeoutMs` | 120,000 | Shell command timeout (ms) |
227
+ | `bgCleanupMs` | 600,000 | Background process auto-cleanup (10 min) |
228
+ | `readFileMaxSize` | 50 MB | Max file size for read operations |
210
229
 
211
230
  ### Project Files
212
231
 
@@ -222,6 +241,14 @@ Config is stored in `~/.scalix-coder/config.json`.
222
241
 
223
242
  ## VS Code Extension
224
243
 
244
+ 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:
245
+
246
+ ```bash
247
+ code --install-extension scalix.scalix
248
+ ```
249
+
250
+ Or build from source with the steps below.
251
+
225
252
  Full-featured sidebar assistant with streaming chat, tab completion, inline chat, inline diffs, codebase search, and checkpoints.
226
253
 
227
254
  See [vscode-extension/README.md](vscode-extension/README.md) for setup and usage.
@@ -110,7 +110,8 @@ export class AgentReplay {
110
110
  async save() {
111
111
  await fs.mkdir(REPLAYS_DIR, { recursive: true });
112
112
  const filePath = path.join(REPLAYS_DIR, `${this.session.id}.json`);
113
- await fs.writeFile(filePath, JSON.stringify(this.session, null, 2), 'utf-8');
113
+ // Replay files carry conversation content — same owner-only mode as sessions.
114
+ await fs.writeFile(filePath, JSON.stringify(this.session, null, 2), { encoding: 'utf-8', mode: 0o600 });
114
115
  return filePath;
115
116
  }
116
117
  static async load(sessionId) {
package/dist/agent.js CHANGED
@@ -1,10 +1,10 @@
1
- import { ScalixClient, ApiError } from './api-client.js';
1
+ import { ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { getToolPermissionWithArgs } from './sandbox.js';
5
5
  import { logger } from './logger.js';
6
- import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, modelSupportsVision, getModelSpec } from './constants.js';
7
- import { estimateMessageTokens as estimateTokenCount } from './token-counter.js';
6
+ import { DEFAULT_MODELS as FALLBACK_MODELS, DEFAULT_INSTRUCTIONS, DEFAULT_BASE_URL, AGENT_DEFAULTS, TOOL_DEFAULTS, BACKOFF_DEFAULTS, CONTEXT_DEFAULTS, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
7
+ import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
8
8
  import { ContextManager } from './context-manager.js';
9
9
  import { zodToJsonSchema } from './tool-helper.js';
10
10
  import { ToolSelector } from './tool-selector.js';
@@ -20,6 +20,14 @@ import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
22
  import { fetchCloudTools, callCloudTool } from './cloud.js';
23
+ import { createChatClient, capabilitiesFor } from './providers/index.js';
24
+ /**
25
+ * Marks an error that Agent has already announced through the 'error' event.
26
+ * `send()` emits and then rethrows, so a caller that also prints from its catch
27
+ * block renders every failure twice — once with the model name and a recovery
28
+ * hint, then again bare. Callers should check this before printing.
29
+ */
30
+ export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
23
31
  function transformToolsForApi(tools) {
24
32
  return tools
25
33
  // type:'mcp' entries are decorative config for user-registered external
@@ -137,6 +145,8 @@ export class Agent extends EventEmitter {
137
145
  modelIndex = 0;
138
146
  log;
139
147
  contextManager;
148
+ /** The context window the user configured, if any. See syncContextLimit(). */
149
+ configuredContextTokens;
140
150
  toolSelector = null;
141
151
  _aborted = false;
142
152
  _thinkingActive = false;
@@ -165,12 +175,20 @@ export class Agent extends EventEmitter {
165
175
  outputFormatInstructionIndex = null;
166
176
  cloudProjectId;
167
177
  cloudStatus = { state: 'no-key', toolCount: 0 };
178
+ providerId;
179
+ capabilities;
180
+ streamingToolCallsResolved;
168
181
  constructor(config) {
169
182
  super();
170
183
  this.agentId = config.agentId ?? generateAgentId();
171
184
  this.parentAgentId = config.parentAgentId ?? null;
172
185
  this.agentDescription = config.agentDescription ?? 'main';
173
- this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
186
+ this.providerId = config.provider ?? 'scalix';
187
+ this.capabilities = capabilitiesFor(this.providerId);
188
+ if (this.providerId !== 'scalix' && !config.baseUrl) {
189
+ throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
190
+ }
191
+ this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
174
192
  this.log = logger.child();
175
193
  const resolvedModels = Array.isArray(config.model)
176
194
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -198,6 +216,9 @@ export class Agent extends EventEmitter {
198
216
  };
199
217
  this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
200
218
  this.contextManager = new ContextManager(config.contextManager);
219
+ // Kept separately because syncContextLimit() overwrites the manager's own
220
+ // value on every read and would otherwise have nothing to fall back to.
221
+ this.configuredContextTokens = config.contextManager?.maxContextTokens;
201
222
  if (config.dynamicToolSelection) {
202
223
  this.toolSelector = new ToolSelector();
203
224
  this.toolSelector.index(this.config.tools);
@@ -214,6 +235,7 @@ export class Agent extends EventEmitter {
214
235
  setAgentToolConfig({
215
236
  apiKey: config.apiKey,
216
237
  baseUrl: config.baseUrl,
238
+ provider: this.providerId,
217
239
  models: this.config.models,
218
240
  permissionMode: this.config.permissionMode,
219
241
  confirmToolCall: this.config.confirmToolCall,
@@ -260,6 +282,39 @@ export class Agent extends EventEmitter {
260
282
  this.modelIndex = (startIdx + 1) % this.config.models.length;
261
283
  return this.config.models[startIdx % this.config.models.length];
262
284
  }
285
+ /** Provider-scoped max_tokens policy. Scalix: always send, clamped to the
286
+ * platform cap (omitting it lets the platform apply a much smaller default
287
+ * and truncate the answer mid-sentence). OpenAI-compatible BYOK: send only a
288
+ * user/API-registered ceiling — generic endpoints reject values above the
289
+ * target model's real limit. */
290
+ maxTokensField(model) {
291
+ if (this.capabilities.maxTokensPolicy === 'clamped') {
292
+ return { max_tokens: maxOutputTokensForRequest(model) };
293
+ }
294
+ return hasExplicitModelSpec(model)
295
+ ? { max_tokens: getModelSpec(model).maxOutputTokens }
296
+ : {};
297
+ }
298
+ /**
299
+ * Whether an agentic turn can stream against this endpoint. BYOK endpoints
300
+ * speak the OpenAI wire and always stream tool-call deltas, so the static
301
+ * capability answers for them. The Scalix path is a version question, not a
302
+ * provider one: routers that predate streamed tool calls drop `tools` on the
303
+ * stream and answer in prose, and a published CLI does not choose which
304
+ * deployment it is pointed at. There is no version header on the inference
305
+ * path, so it asks /models, which advertises what the deployment can do.
306
+ * Resolved once per session; an unreachable or older endpoint answers "no"
307
+ * and the turn goes non-streaming, exactly as before.
308
+ */
309
+ async streamsToolCalls() {
310
+ if (this.capabilities.streamingToolCalls)
311
+ return true;
312
+ if (this.streamingToolCallsResolved === undefined) {
313
+ const caps = await this.client.endpointCapabilities();
314
+ this.streamingToolCallsResolved = caps.streaming_tool_calls === true;
315
+ }
316
+ return this.streamingToolCallsResolved;
317
+ }
263
318
  shouldFallbackToNext(err) {
264
319
  const e = err;
265
320
  const message = (e?.message || e?.error?.message || '').toLowerCase();
@@ -328,6 +383,14 @@ export class Agent extends EventEmitter {
328
383
  setInstructions(instructions) { this.config.instructions = instructions; }
329
384
  getPermissionMode() { return this.config.permissionMode; }
330
385
  setPermissionMode(mode) { this.config.permissionMode = mode; }
386
+ getProvider() { return this.providerId; }
387
+ /**
388
+ * Whether this session's inference endpoint is the Scalix gateway, and so
389
+ * can answer /v1/me and serve the account's platform-tool catalog. Callers
390
+ * must check this before probing those endpoints: on BYOK the base URL is a
391
+ * third-party host that has no such routes.
392
+ */
393
+ supportsPlatformTools() { return this.capabilities.platformTools; }
331
394
  getThinking() { return this.config.thinkingMode; }
332
395
  setThinking(mode) { this.config.thinkingMode = mode; }
333
396
  async compact() {
@@ -345,6 +408,7 @@ export class Agent extends EventEmitter {
345
408
  const result = await this.client.chatComplete({
346
409
  model,
347
410
  messages: apiMessages,
411
+ ...this.maxTokensField(model),
348
412
  stream: false,
349
413
  }, this._abortController?.signal);
350
414
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
@@ -368,8 +432,64 @@ export class Agent extends EventEmitter {
368
432
  return `Compaction failed: ${err.message}`;
369
433
  }
370
434
  }
435
+ /**
436
+ * Point the context manager at the active model's real input budget. The
437
+ * manager starts on a generic 1M default, so until this runs every reader —
438
+ * /status, /context, the editor's context ring — quotes a window the model
439
+ * does not have. It used to run only inside the send loop, which meant a
440
+ * fresh session reported 1M until the first reply landed.
441
+ *
442
+ * Precedence: a registered spec (user-defined or fetched from the endpoint)
443
+ * is per-model truth and wins. Without one, getModelSpec() hands back a
444
+ * generic 200K/64K guess — and a guess must not overwrite a window the user
445
+ * typed into the setup wizard. It did, which is why a BYOK 8K local model was
446
+ * still being treated as a 134K one: nothing compressed until the endpoint
447
+ * itself started rejecting the oversized request. The untouched 1M default
448
+ * isn't a choice, so it stays out of the way of the guess.
449
+ */
450
+ syncContextLimit(model = this.config.models[this.modelIndex % this.config.models.length]) {
451
+ const configured = this.configuredContextTokens;
452
+ const userChoseLimit = configured != null
453
+ && configured > 0
454
+ && configured !== CONTEXT_DEFAULTS.maxContextTokens;
455
+ if (userChoseLimit && !hasExplicitModelSpec(model)) {
456
+ this.contextManager.setMaxContextTokens(configured);
457
+ return;
458
+ }
459
+ const spec = getModelSpec(model);
460
+ const inputBudget = spec.contextWindow - spec.maxOutputTokens;
461
+ if (inputBudget > 0)
462
+ this.contextManager.setMaxContextTokens(inputBudget);
463
+ }
371
464
  getContextUsage() {
372
- return this.contextManager.getTokenUsage(this.messages);
465
+ this.syncContextLimit();
466
+ const usage = this.contextManager.getTokenUsage(this.messages);
467
+ // messagesToApi() prepends the instructions at request time, so the system
468
+ // prompt is never in this.messages — but it is sent on every request and
469
+ // occupies the window like anything else. Counting it keeps /context,
470
+ // /status and the editor's context ring honest instead of quietly low.
471
+ const tokens = usage.tokens + estimateTokens(this.config.instructions || '');
472
+ return {
473
+ tokens,
474
+ maxTokens: usage.maxTokens,
475
+ percentage: Math.round((tokens / usage.maxTokens) * 100),
476
+ };
477
+ }
478
+ /**
479
+ * Per-role token split of the current context. estimateMessageTokens() is
480
+ * additive per message, so grouping by role and summing the groups gives
481
+ * exactly getContextUsage().tokens — these are measured, not apportioned.
482
+ */
483
+ getContextBreakdown() {
484
+ const bucket = { system: [], user: [], assistant: [], tool: [] };
485
+ for (const m of this.messages)
486
+ bucket[m.role]?.push(m);
487
+ return {
488
+ system: estimateTokens(this.config.instructions || '') + estimateTokenCount(bucket.system),
489
+ user: estimateTokenCount(bucket.user),
490
+ assistant: estimateTokenCount(bucket.assistant),
491
+ tools: estimateTokenCount(bucket.tool),
492
+ };
373
493
  }
374
494
  async autoCompress(reqLog) {
375
495
  if (!this.contextManager.shouldCompress(this.messages)) {
@@ -400,6 +520,7 @@ export class Agent extends EventEmitter {
400
520
  { role: 'system', content: 'You compress conversation history. Preserve every file path, function name, error message, code change, command, config value, and user instruction verbatim. Omit greetings and filler only. This output replaces the original — anything omitted is permanently lost.' },
401
521
  { role: 'user', content: prompt },
402
522
  ],
523
+ ...this.maxTokensField(model),
403
524
  stream: false,
404
525
  }, this._abortController?.signal);
405
526
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
@@ -462,6 +583,13 @@ export class Agent extends EventEmitter {
462
583
  * status carries the real error for a single honest notice; no retries.
463
584
  */
464
585
  async refreshCloudTools() {
586
+ // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp). Never
587
+ // probe a BYOK third-party endpoint for them.
588
+ if (!this.capabilities.platformTools) {
589
+ this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
590
+ this.cloudStatus = { state: 'no-key', toolCount: 0 };
591
+ return { ...this.cloudStatus };
592
+ }
465
593
  if (!this.config.apiKey) {
466
594
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
467
595
  this.cloudStatus = { state: 'no-key', toolCount: 0 };
@@ -731,13 +859,24 @@ export class Agent extends EventEmitter {
731
859
  catch (err) {
732
860
  this.deregisterFromRegistry('failed');
733
861
  const error = err instanceof Error ? err : new Error(String(err));
862
+ const status = err.status ?? err.statusCode;
863
+ // 401 is an onboarding problem, not a request problem — point at the fix.
864
+ // Which fix depends on whose key was rejected: on BYOK the request went to
865
+ // the user's own endpoint, so sending them to the Scalix console to mint a
866
+ // platform key is a dead end that doesn't touch the credential that failed.
867
+ if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
868
+ 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.'
870
+ : ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
871
+ }
734
872
  error.metadata = {
735
873
  model: this.config.models[this.modelIndex % this.config.models.length],
736
- status: err.status ?? err.statusCode,
874
+ status,
737
875
  error: err.error,
738
876
  requestId,
739
877
  };
740
878
  reqLog.error('Agent send failed', { error: error.message });
879
+ error[REPORTED_ERROR] = true;
741
880
  this.emit('error', error);
742
881
  throw error;
743
882
  }
@@ -750,6 +889,7 @@ export class Agent extends EventEmitter {
750
889
  let turnCount = 0;
751
890
  let retryAttempt = 0;
752
891
  let emptyTurnCount = 0;
892
+ const MAX_EMPTY_TURNS = 5;
753
893
  let disableTools = false;
754
894
  let forceNonStreaming = false;
755
895
  while (turnCount < this.config.maxSteps) {
@@ -764,13 +904,16 @@ export class Agent extends EventEmitter {
764
904
  const toolCalls = new Map();
765
905
  try {
766
906
  // Sync context limit to the current model's actual context window
767
- const modelSpec = getModelSpec(currentModel);
768
- const inputBudget = modelSpec.contextWindow - modelSpec.maxOutputTokens;
769
- if (inputBudget > 0) {
770
- this.contextManager.setMaxContextTokens(inputBudget);
771
- }
907
+ this.syncContextLimit(currentModel);
772
908
  await this.autoCompress(reqLog);
773
- this.emit('context:usage', this.contextManager.getTokenUsage(this.messages));
909
+ // getContextUsage(), not contextManager.getTokenUsage(): the former adds
910
+ // the system prompt, and a listener that renders both this event and a
911
+ // getContextInfo() response must not see two different totals.
912
+ this.emit('context:usage', {
913
+ ...this.getContextUsage(),
914
+ messageCount: this.messages.length,
915
+ breakdown: this.getContextBreakdown(),
916
+ });
774
917
  let toolsForRequest = this.config.tools;
775
918
  if (this.toolSelector) {
776
919
  const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
@@ -807,13 +950,20 @@ export class Agent extends EventEmitter {
807
950
  const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
808
951
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
809
952
  const hasToolMessages = this.messages.some(m => m.role === 'tool' || (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0));
810
- const useStreaming = !hasToolMessages && !forceNonStreaming;
953
+ // An endpoint that does not carry tool calls over SSE would answer an
954
+ // agentic turn in prose and lose the calls, so those turns go
955
+ // non-streaming there and the user watches a spinner instead of output.
956
+ // Text-only turns stream either way.
957
+ const useStreaming = !forceNonStreaming && (await this.streamsToolCalls()
958
+ ? true
959
+ : (!apiTools || apiTools.length === 0) && !hasToolMessages);
811
960
  if (useStreaming) {
812
961
  const stream = await this.client.chatComplete({
813
962
  model: currentModel,
814
963
  messages: apiMessages,
815
964
  tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
816
965
  temperature: 0.7,
966
+ ...this.maxTokensField(currentModel),
817
967
  stream: true,
818
968
  ...(thinkingLevel && { thinking_level: thinkingLevel }),
819
969
  }, this._abortController.signal);
@@ -869,6 +1019,7 @@ export class Agent extends EventEmitter {
869
1019
  messages: apiMessages,
870
1020
  tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
871
1021
  temperature: 0.7,
1022
+ ...this.maxTokensField(currentModel),
872
1023
  stream: false,
873
1024
  ...(thinkingLevel && { thinking_level: thinkingLevel }),
874
1025
  }, this._abortController.signal);
@@ -928,10 +1079,21 @@ export class Agent extends EventEmitter {
928
1079
  disableTools = true;
929
1080
  reqLog.warn('Disabling tools due to repeated empty turns — model may not support function calling');
930
1081
  }
931
- if (emptyTurnCount >= 5) {
1082
+ if (emptyTurnCount >= MAX_EMPTY_TURNS) {
932
1083
  reqLog.error('Too many empty turns, aborting');
933
1084
  return '[no response]';
934
1085
  }
1086
+ // Re-asking used to be entirely silent: from the TUI this is several
1087
+ // seconds of spinner followed, on the last attempt, by nothing at
1088
+ // all. Reuse the retry event so the transcript says what happened.
1089
+ // Emitted after the bail check, so the count only ever describes
1090
+ // retries that actually happen.
1091
+ this.emit('retry', {
1092
+ attempt: emptyTurnCount,
1093
+ maxAttempts: MAX_EMPTY_TURNS - 1,
1094
+ delayMs: 0,
1095
+ reason: 'Model returned an empty response',
1096
+ });
935
1097
  continue;
936
1098
  }
937
1099
  emptyTurnCount = 0;
@@ -1063,6 +1225,26 @@ export class Agent extends EventEmitter {
1063
1225
  const args = tool?.source === 'scalix-cloud'
1064
1226
  ? parsedArgs
1065
1227
  : normalizeArgs(parsedArgs);
1228
+ // Validate against the very schema the model was shown. Nothing
1229
+ // checked this before, so a hallucinated parameter name reached
1230
+ // the tool body and came back as a Node internals message —
1231
+ // read_file called with {path} instead of {filePath} answered
1232
+ // 'The "paths[1]" argument must be of type string. Received
1233
+ // undefined', which tells the model nothing it can act on. Args
1234
+ // are passed through unchanged on success rather than swapped
1235
+ // for the parsed copy, since z.object() strips unknown keys and
1236
+ // some tools read fields their schema doesn't declare.
1237
+ const schema = tool?.function?.inputSchema ?? tool?.inputSchema;
1238
+ if (schema && typeof schema.safeParse === 'function') {
1239
+ const check = schema.safeParse(args);
1240
+ if (!check.success) {
1241
+ const issues = (check.error?.issues ?? [])
1242
+ .slice(0, 4)
1243
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
1244
+ .join('; ');
1245
+ throw new Error(`Invalid arguments for ${call.name} — ${issues || 'schema validation failed'}`);
1246
+ }
1247
+ }
1066
1248
  reqLog.debug('Executing tool', { tool: call.name });
1067
1249
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
1068
1250
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
@@ -1236,11 +1418,21 @@ export class Agent extends EventEmitter {
1236
1418
  if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
1237
1419
  retryAttempt++;
1238
1420
  forceNonStreaming = false;
1239
- const delay = backoffDelay(retryAttempt);
1421
+ // Honor the server's Retry-After on 429/503 (capped at 60s) instead
1422
+ // of a blind exponential guess; fall back to backoff otherwise.
1423
+ const serverWait = err instanceof ApiError ? err.retryAfterMs : undefined;
1424
+ const delay = serverWait !== undefined ? Math.min(serverWait, 60_000) : backoffDelay(retryAttempt);
1240
1425
  reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
1241
1426
  model: currentModel,
1242
1427
  error: err.message,
1243
1428
  });
1429
+ this.emit('retry', {
1430
+ attempt: retryAttempt,
1431
+ maxAttempts: this.config.maxRetries,
1432
+ delayMs: Math.round(delay),
1433
+ status: err instanceof ApiError ? err.status : undefined,
1434
+ reason: err.message,
1435
+ });
1244
1436
  await sleep(delay);
1245
1437
  currentModel = this.pickModel();
1246
1438
  continue;