scalix 1.4.1 → 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 +36 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +207 -15
- package/dist/api-client.js +150 -25
- package/dist/cli.js +516 -213
- package/dist/constants.js +61 -6
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +24 -14
- package/dist/ipc-server.js +26 -8
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +86 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/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
|
@@ -6,7 +6,7 @@ AI-powered coding agent for the terminal and [VS Code](https://marketplace.visua
|
|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
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
|
|
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
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
|
|
23
|
-
- **
|
|
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
|
|
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` |
|
|
203
|
-
| `maxMessages` |
|
|
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.
|
|
206
|
-
| `maxToolResultTokens` |
|
|
207
|
-
| `commandTimeoutMs` |
|
|
208
|
-
| `bgCleanupMs` |
|
|
209
|
-
| `readFileMaxSize` |
|
|
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
|
|
package/dist/agent-replay.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
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;
|