scalix 1.4.1 → 1.5.1

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/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, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, 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';
@@ -19,7 +19,15 @@ import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFA
19
19
  import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
20
20
  import fs from 'node:fs/promises';
21
21
  import path from 'node:path';
22
- import { fetchCloudTools, callCloudTool } from './cloud.js';
22
+ import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } 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,30 @@ 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
+ cloudApiKey;
181
+ cloudBaseUrl;
182
+ streamingToolCallsResolved;
168
183
  constructor(config) {
169
184
  super();
170
185
  this.agentId = config.agentId ?? generateAgentId();
171
186
  this.parentAgentId = config.parentAgentId ?? null;
172
187
  this.agentDescription = config.agentDescription ?? 'main';
173
- this.client = new ScalixClient(config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
188
+ this.providerId = config.provider ?? 'scalix';
189
+ this.capabilities = capabilitiesFor(this.providerId);
190
+ if (this.providerId !== 'scalix' && !config.baseUrl) {
191
+ throw new Error(`Provider '${this.providerId}' requires an explicit baseUrl — there is no default third-party endpoint.`);
192
+ }
193
+ this.client = createChatClient(this.providerId, config.baseUrl || DEFAULT_BASE_URL, config.apiKey);
194
+ // Cloud tools follow their own credential pair. On the Scalix gateway the
195
+ // inference creds double as the cloud creds; on BYOK `config.baseUrl` is a
196
+ // third-party host with no /v1/me, so the cloud URL falls back to the
197
+ // Scalix default rather than inheriting it.
198
+ this.cloudApiKey = config.cloudApiKey?.trim()
199
+ || (this.capabilities.platformTools ? config.apiKey : '');
200
+ this.cloudBaseUrl = config.cloudBaseUrl?.trim()
201
+ || (this.capabilities.platformTools ? (config.baseUrl || DEFAULT_BASE_URL) : DEFAULT_BASE_URL);
174
202
  this.log = logger.child();
175
203
  const resolvedModels = Array.isArray(config.model)
176
204
  ? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
@@ -198,6 +226,9 @@ export class Agent extends EventEmitter {
198
226
  };
199
227
  this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
200
228
  this.contextManager = new ContextManager(config.contextManager);
229
+ // Kept separately because syncContextLimit() overwrites the manager's own
230
+ // value on every read and would otherwise have nothing to fall back to.
231
+ this.configuredContextTokens = config.contextManager?.maxContextTokens;
201
232
  if (config.dynamicToolSelection) {
202
233
  this.toolSelector = new ToolSelector();
203
234
  this.toolSelector.index(this.config.tools);
@@ -214,6 +245,7 @@ export class Agent extends EventEmitter {
214
245
  setAgentToolConfig({
215
246
  apiKey: config.apiKey,
216
247
  baseUrl: config.baseUrl,
248
+ provider: this.providerId,
217
249
  models: this.config.models,
218
250
  permissionMode: this.config.permissionMode,
219
251
  confirmToolCall: this.config.confirmToolCall,
@@ -246,7 +278,16 @@ export class Agent extends EventEmitter {
246
278
  this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
247
279
  }
248
280
  }
249
- pickModel() {
281
+ /**
282
+ * `fresh` — a NEW user turn: always start from the front of the list (the
283
+ * user's chosen model). The idx+1 bookkeeping below exists so RETRIES
284
+ * advance to the next model in the chain; without the fresh reset it also
285
+ * rotated the STARTING model, so with a failover chain configured,
286
+ * consecutive sends silently alternated models — a switch never "stuck".
287
+ */
288
+ pickModel(fresh = false) {
289
+ if (fresh)
290
+ this.modelIndex = 0;
250
291
  const startIdx = this.modelIndex;
251
292
  for (let i = 0; i < this.config.models.length; i++) {
252
293
  const idx = (startIdx + i) % this.config.models.length;
@@ -260,6 +301,39 @@ export class Agent extends EventEmitter {
260
301
  this.modelIndex = (startIdx + 1) % this.config.models.length;
261
302
  return this.config.models[startIdx % this.config.models.length];
262
303
  }
304
+ /** Provider-scoped max_tokens policy. Scalix: always send, clamped to the
305
+ * platform cap (omitting it lets the platform apply a much smaller default
306
+ * and truncate the answer mid-sentence). OpenAI-compatible BYOK: send only a
307
+ * user/API-registered ceiling — generic endpoints reject values above the
308
+ * target model's real limit. */
309
+ maxTokensField(model) {
310
+ if (this.capabilities.maxTokensPolicy === 'clamped') {
311
+ return { max_tokens: maxOutputTokensForRequest(model) };
312
+ }
313
+ return hasExplicitModelSpec(model)
314
+ ? { max_tokens: getModelSpec(model).maxOutputTokens }
315
+ : {};
316
+ }
317
+ /**
318
+ * Whether an agentic turn can stream against this endpoint. BYOK endpoints
319
+ * speak the OpenAI wire and always stream tool-call deltas, so the static
320
+ * capability answers for them. The Scalix path is a version question, not a
321
+ * provider one: routers that predate streamed tool calls drop `tools` on the
322
+ * stream and answer in prose, and a published CLI does not choose which
323
+ * deployment it is pointed at. There is no version header on the inference
324
+ * path, so it asks /models, which advertises what the deployment can do.
325
+ * Resolved once per session; an unreachable or older endpoint answers "no"
326
+ * and the turn goes non-streaming, exactly as before.
327
+ */
328
+ async streamsToolCalls() {
329
+ if (this.capabilities.streamingToolCalls)
330
+ return true;
331
+ if (this.streamingToolCallsResolved === undefined) {
332
+ const caps = await this.client.endpointCapabilities();
333
+ this.streamingToolCallsResolved = caps.streaming_tool_calls === true;
334
+ }
335
+ return this.streamingToolCallsResolved;
336
+ }
263
337
  shouldFallbackToNext(err) {
264
338
  const e = err;
265
339
  const message = (e?.message || e?.error?.message || '').toLowerCase();
@@ -328,6 +402,20 @@ export class Agent extends EventEmitter {
328
402
  setInstructions(instructions) { this.config.instructions = instructions; }
329
403
  getPermissionMode() { return this.config.permissionMode; }
330
404
  setPermissionMode(mode) { this.config.permissionMode = mode; }
405
+ getProvider() { return this.providerId; }
406
+ /**
407
+ * Whether this session can reach a Scalix gateway that answers /v1/me and
408
+ * serves the account's platform-tool catalog. True when inference runs on
409
+ * the gateway, or when BYOK inference is paired with a separate Scalix Cloud
410
+ * key. False means no endpoint to probe — never probe the BYOK base URL.
411
+ */
412
+ supportsPlatformTools() { return this.cloudApiKey.length > 0; }
413
+ /** The Scalix Cloud endpoint platform tools talk to — the gateway, not the
414
+ * inference base URL, which under BYOK is a third-party host. */
415
+ getCloudBaseUrl() { return this.cloudBaseUrl; }
416
+ /** The key platform tools authenticate with — equal to the inference key on
417
+ * the 'scalix' provider, separate under BYOK, empty if unconfigured. */
418
+ getCloudApiKey() { return this.cloudApiKey; }
331
419
  getThinking() { return this.config.thinkingMode; }
332
420
  setThinking(mode) { this.config.thinkingMode = mode; }
333
421
  async compact() {
@@ -345,6 +433,7 @@ export class Agent extends EventEmitter {
345
433
  const result = await this.client.chatComplete({
346
434
  model,
347
435
  messages: apiMessages,
436
+ ...this.maxTokensField(model),
348
437
  stream: false,
349
438
  }, this._abortController?.signal);
350
439
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
@@ -368,8 +457,68 @@ export class Agent extends EventEmitter {
368
457
  return `Compaction failed: ${err.message}`;
369
458
  }
370
459
  }
460
+ /**
461
+ * Point the context manager at the active model's real input budget. The
462
+ * manager starts on a generic 1M default, so until this runs every reader —
463
+ * /status, /context, the editor's context ring — quotes a window the model
464
+ * does not have. It used to run only inside the send loop, which meant a
465
+ * fresh session reported 1M until the first reply landed.
466
+ *
467
+ * Precedence: a registered spec (user-defined or fetched from the endpoint)
468
+ * is per-model truth and wins. Without one, getModelSpec() hands back a
469
+ * generic 200K/64K guess — and a guess must not overwrite a window the user
470
+ * typed into the setup wizard. It did, which is why a BYOK 8K local model was
471
+ * still being treated as a 134K one: nothing compressed until the endpoint
472
+ * itself started rejecting the oversized request. The untouched 1M default
473
+ * isn't a choice, so it stays out of the way of the guess.
474
+ */
475
+ // Default to models[0] — the model a fresh send will use. modelIndex points
476
+ // one PAST the last pick (retry bookkeeping), so dereferencing it here made
477
+ // idle-time context reads use the NEXT chain entry's budget, not the
478
+ // active model's.
479
+ syncContextLimit(model = this.config.models[0]) {
480
+ const configured = this.configuredContextTokens;
481
+ const userChoseLimit = configured != null
482
+ && configured > 0
483
+ && configured !== CONTEXT_DEFAULTS.maxContextTokens;
484
+ if (userChoseLimit && !hasExplicitModelSpec(model)) {
485
+ this.contextManager.setMaxContextTokens(configured);
486
+ return;
487
+ }
488
+ const spec = getModelSpec(model);
489
+ const inputBudget = spec.contextWindow - spec.maxOutputTokens;
490
+ if (inputBudget > 0)
491
+ this.contextManager.setMaxContextTokens(inputBudget);
492
+ }
371
493
  getContextUsage() {
372
- return this.contextManager.getTokenUsage(this.messages);
494
+ this.syncContextLimit();
495
+ const usage = this.contextManager.getTokenUsage(this.messages);
496
+ // messagesToApi() prepends the instructions at request time, so the system
497
+ // prompt is never in this.messages — but it is sent on every request and
498
+ // occupies the window like anything else. Counting it keeps /context,
499
+ // /status and the editor's context ring honest instead of quietly low.
500
+ const tokens = usage.tokens + estimateTokens(this.config.instructions || '');
501
+ return {
502
+ tokens,
503
+ maxTokens: usage.maxTokens,
504
+ percentage: Math.round((tokens / usage.maxTokens) * 100),
505
+ };
506
+ }
507
+ /**
508
+ * Per-role token split of the current context. estimateMessageTokens() is
509
+ * additive per message, so grouping by role and summing the groups gives
510
+ * exactly getContextUsage().tokens — these are measured, not apportioned.
511
+ */
512
+ getContextBreakdown() {
513
+ const bucket = { system: [], user: [], assistant: [], tool: [] };
514
+ for (const m of this.messages)
515
+ bucket[m.role]?.push(m);
516
+ return {
517
+ system: estimateTokens(this.config.instructions || '') + estimateTokenCount(bucket.system),
518
+ user: estimateTokenCount(bucket.user),
519
+ assistant: estimateTokenCount(bucket.assistant),
520
+ tools: estimateTokenCount(bucket.tool),
521
+ };
373
522
  }
374
523
  async autoCompress(reqLog) {
375
524
  if (!this.contextManager.shouldCompress(this.messages)) {
@@ -400,6 +549,7 @@ export class Agent extends EventEmitter {
400
549
  { 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
550
  { role: 'user', content: prompt },
402
551
  ],
552
+ ...this.maxTokensField(model),
403
553
  stream: false,
404
554
  }, this._abortController?.signal);
405
555
  const summary = result.choices?.[0]?.message?.content?.trim() || '';
@@ -438,8 +588,8 @@ export class Agent extends EventEmitter {
438
588
  * Schema is passed to the model as-is via `parameters` (NOT `inputSchema`,
439
589
  * which transformToolsForApi would run through the Zod converter). */
440
590
  makeCloudTool(ct) {
441
- const baseUrl = this.client.baseURL;
442
- const apiKey = this.config.apiKey;
591
+ const baseUrl = this.cloudBaseUrl;
592
+ const apiKey = this.cloudApiKey;
443
593
  const projectId = this.cloudProjectId;
444
594
  return {
445
595
  source: 'scalix-cloud',
@@ -460,19 +610,41 @@ export class Agent extends EventEmitter {
460
610
  * (/cloud refresh). On failure the previously registered cloud tools are
461
611
  * removed and the agent keeps running with local tools only — the returned
462
612
  * status carries the real error for a single honest notice; no retries.
613
+ *
614
+ * `identity`: pass a CloudIdentity to scope-filter the catalog without a
615
+ * second /v1/me round-trip (the TUI already fetched one); pass `null` when
616
+ * /v1/me was tried and failed so no doomed re-fetch happens; omit entirely
617
+ * (headless, IPC) and the identity is fetched best-effort here — on any
618
+ * /v1/me failure the catalog registers unfiltered, exactly as before.
463
619
  */
464
- async refreshCloudTools() {
465
- if (!this.config.apiKey) {
620
+ async refreshCloudTools(identity) {
621
+ // Platform tools live on the Scalix gateway (/v1/me, /api/v1/mcp), which
622
+ // is `cloudBaseUrl` — never the BYOK inference endpoint. No cloud key
623
+ // means nothing to fetch: on 'scalix' the user has no key at all, on BYOK
624
+ // they have not opted into pairing a Scalix Cloud key with their provider.
625
+ if (!this.cloudApiKey) {
466
626
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
467
627
  this.cloudStatus = { state: 'no-key', toolCount: 0 };
468
628
  return { ...this.cloudStatus };
469
629
  }
470
630
  try {
471
- const catalog = await fetchCloudTools(this.client.baseURL, this.config.apiKey, this.cloudProjectId);
631
+ const catalog = await fetchCloudTools(this.cloudBaseUrl, this.cloudApiKey, this.cloudProjectId);
632
+ let me = identity;
633
+ if (me === undefined) {
634
+ try {
635
+ me = await fetchMe(this.cloudBaseUrl, this.cloudApiKey);
636
+ }
637
+ catch {
638
+ me = null;
639
+ }
640
+ }
641
+ const visible = me
642
+ ? filterCatalogByScopes(catalog.tools, me.permissions.scopes)
643
+ : catalog.tools;
472
644
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
473
645
  const localNames = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
474
646
  let registered = 0;
475
- for (const ct of catalog.tools) {
647
+ for (const ct of visible) {
476
648
  if (localNames.has(ct.name)) {
477
649
  this.log.warn('Cloud tool name collides with a local tool — keeping the local tool', { tool: ct.name });
478
650
  continue;
@@ -485,14 +657,26 @@ export class Agent extends EventEmitter {
485
657
  this.cloudStatus = {
486
658
  state: 'connected',
487
659
  toolCount: registered,
660
+ totalCount: catalog.tools.length,
488
661
  platformAvailable: catalog.platformAvailable,
489
662
  };
663
+ // The platform binds MCP calls to the KEY's project; a configured
664
+ // project id cannot redirect them. Say so instead of silently differing.
665
+ const keyProject = me?.identity.projectId;
666
+ if (keyProject && this.cloudProjectId && keyProject !== this.cloudProjectId) {
667
+ this.cloudStatus.note = `API key is bound to project ${keyProject}; configured project ${this.cloudProjectId} is ignored for platform tools (the key's binding wins).`;
668
+ }
490
669
  }
491
670
  catch (err) {
492
671
  this.config.tools = this.config.tools.filter((t) => !this.isCloudTool(t));
493
672
  if (this.toolSelector)
494
673
  this.toolSelector.index(this.config.tools);
495
- this.cloudStatus = { state: 'unavailable', toolCount: 0, error: err?.message || String(err) };
674
+ this.cloudStatus = {
675
+ state: 'unavailable',
676
+ toolCount: 0,
677
+ error: err?.message || String(err),
678
+ remedy: cloudKeyRemedy(err),
679
+ };
496
680
  this.log.warn('Scalix Cloud tool catalog unavailable', { error: this.cloudStatus.error });
497
681
  }
498
682
  return { ...this.cloudStatus };
@@ -510,12 +694,39 @@ export class Agent extends EventEmitter {
510
694
  if (!this.config.models.includes(model))
511
695
  this.config.models.push(model);
512
696
  }
513
- setModel(model) { this.config.models = [model]; }
697
+ setModel(model) {
698
+ // Mutate IN PLACE, never reassign: setAgentToolConfig captured this array
699
+ // by reference at construction, so a reassignment left sub-agents on the
700
+ // pre-switch model list forever. And keep the failover chain — replacing
701
+ // the whole list with [model] permanently destroyed a configured
702
+ // multi-model chain; the switched-to model just moves to the front.
703
+ const rest = this.config.models.filter((m) => m !== model);
704
+ this.config.models.splice(0, this.config.models.length, model, ...rest);
705
+ this.modelIndex = 0;
706
+ // A deliberate switch is a vote of confidence: clear this model's breaker
707
+ // so the next request actually tries it, and resize the context budget
708
+ // now instead of at the next lazy resync.
709
+ this.modelFailures.delete(model);
710
+ this.syncContextLimit(model);
711
+ }
514
712
  getModels() { return [...this.config.models]; }
713
+ /** Live-apply a limit. Returns true when the running agent honors the new
714
+ * value immediately, false when it only takes effect on the next launch —
715
+ * callers used to report "updated" unconditionally, which was a lie for
716
+ * every key that lives outside AgentConfig (web/git/search timeouts, …). */
515
717
  updateLimit(key, value) {
718
+ // Context keys live nested under config.contextManager and on the live
719
+ // ContextManager, not as top-level AgentConfig fields.
720
+ if (key === 'maxContextTokens') {
721
+ this.configuredContextTokens = value;
722
+ this.contextManager.setMaxContextTokens(value);
723
+ return true;
724
+ }
516
725
  if (key in this.config) {
517
726
  this.config[key] = value;
727
+ return true;
518
728
  }
729
+ return false;
519
730
  }
520
731
  getReplay() { return this.replay; }
521
732
  getChangeTracker() { return this.changeTracker; }
@@ -719,7 +930,7 @@ export class Agent extends EventEmitter {
719
930
  reqLog.info('User message received', { length: content.length });
720
931
  this.registerInRegistry();
721
932
  try {
722
- const initialModel = this.pickModel();
933
+ const initialModel = this.pickModel(true);
723
934
  reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
724
935
  let result = await this.loopStreamingTurns(initialModel, reqLog);
725
936
  if (this.outputFormat !== 'text') {
@@ -731,13 +942,24 @@ export class Agent extends EventEmitter {
731
942
  catch (err) {
732
943
  this.deregisterFromRegistry('failed');
733
944
  const error = err instanceof Error ? err : new Error(String(err));
945
+ const status = err.status ?? err.statusCode;
946
+ // 401 is an onboarding problem, not a request problem — point at the fix.
947
+ // Which fix depends on whose key was rejected: on BYOK the request went to
948
+ // the user's own endpoint, so sending them to the Scalix console to mint a
949
+ // platform key is a dead end that doesn't touch the credential that failed.
950
+ if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
951
+ error.message += this.capabilities.platformTools
952
+ ? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys and update it via /setup.'
953
+ : ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
954
+ }
734
955
  error.metadata = {
735
956
  model: this.config.models[this.modelIndex % this.config.models.length],
736
- status: err.status ?? err.statusCode,
957
+ status,
737
958
  error: err.error,
738
959
  requestId,
739
960
  };
740
961
  reqLog.error('Agent send failed', { error: error.message });
962
+ error[REPORTED_ERROR] = true;
741
963
  this.emit('error', error);
742
964
  throw error;
743
965
  }
@@ -750,6 +972,7 @@ export class Agent extends EventEmitter {
750
972
  let turnCount = 0;
751
973
  let retryAttempt = 0;
752
974
  let emptyTurnCount = 0;
975
+ const MAX_EMPTY_TURNS = 5;
753
976
  let disableTools = false;
754
977
  let forceNonStreaming = false;
755
978
  while (turnCount < this.config.maxSteps) {
@@ -764,13 +987,16 @@ export class Agent extends EventEmitter {
764
987
  const toolCalls = new Map();
765
988
  try {
766
989
  // 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
- }
990
+ this.syncContextLimit(currentModel);
772
991
  await this.autoCompress(reqLog);
773
- this.emit('context:usage', this.contextManager.getTokenUsage(this.messages));
992
+ // getContextUsage(), not contextManager.getTokenUsage(): the former adds
993
+ // the system prompt, and a listener that renders both this event and a
994
+ // getContextInfo() response must not see two different totals.
995
+ this.emit('context:usage', {
996
+ ...this.getContextUsage(),
997
+ messageCount: this.messages.length,
998
+ breakdown: this.getContextBreakdown(),
999
+ });
774
1000
  let toolsForRequest = this.config.tools;
775
1001
  if (this.toolSelector) {
776
1002
  const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
@@ -803,17 +1029,42 @@ export class Agent extends EventEmitter {
803
1029
  }
804
1030
  }
805
1031
  }
1032
+ // Scalix steering + live cloud-tools awareness, assembled here — the
1033
+ // one point every frontend (TUI, headless, IPC) funnels through —
1034
+ // and rebuilt per request so a /cloud refresh shows up on the next
1035
+ // turn. Coder is a generic tool: the product steering only appears
1036
+ // while a Scalix Cloud connection is live; on the Scalix provider
1037
+ // without one a single neutral pointer; on pure BYOK nothing at all.
1038
+ if (this.cloudStatus.state === 'connected') {
1039
+ if (!SCALIX_STEERING_ALWAYS_ON) {
1040
+ effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
1041
+ }
1042
+ const liveCloudTools = this.getCloudTools();
1043
+ if (liveCloudTools.length > 0) {
1044
+ effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
1045
+ }
1046
+ }
1047
+ else if (!SCALIX_STEERING_ALWAYS_ON && this.providerId === 'scalix') {
1048
+ effectiveInstructions += '\n\n' + SCALIX_NEUTRAL_POINTER;
1049
+ }
806
1050
  const vision = modelSupportsVision(currentModel);
807
1051
  const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
808
1052
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
809
1053
  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;
1054
+ // An endpoint that does not carry tool calls over SSE would answer an
1055
+ // agentic turn in prose and lose the calls, so those turns go
1056
+ // non-streaming there and the user watches a spinner instead of output.
1057
+ // Text-only turns stream either way.
1058
+ const useStreaming = !forceNonStreaming && (await this.streamsToolCalls()
1059
+ ? true
1060
+ : (!apiTools || apiTools.length === 0) && !hasToolMessages);
811
1061
  if (useStreaming) {
812
1062
  const stream = await this.client.chatComplete({
813
1063
  model: currentModel,
814
1064
  messages: apiMessages,
815
1065
  tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
816
1066
  temperature: 0.7,
1067
+ ...this.maxTokensField(currentModel),
817
1068
  stream: true,
818
1069
  ...(thinkingLevel && { thinking_level: thinkingLevel }),
819
1070
  }, this._abortController.signal);
@@ -869,6 +1120,7 @@ export class Agent extends EventEmitter {
869
1120
  messages: apiMessages,
870
1121
  tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
871
1122
  temperature: 0.7,
1123
+ ...this.maxTokensField(currentModel),
872
1124
  stream: false,
873
1125
  ...(thinkingLevel && { thinking_level: thinkingLevel }),
874
1126
  }, this._abortController.signal);
@@ -928,10 +1180,21 @@ export class Agent extends EventEmitter {
928
1180
  disableTools = true;
929
1181
  reqLog.warn('Disabling tools due to repeated empty turns — model may not support function calling');
930
1182
  }
931
- if (emptyTurnCount >= 5) {
1183
+ if (emptyTurnCount >= MAX_EMPTY_TURNS) {
932
1184
  reqLog.error('Too many empty turns, aborting');
933
1185
  return '[no response]';
934
1186
  }
1187
+ // Re-asking used to be entirely silent: from the TUI this is several
1188
+ // seconds of spinner followed, on the last attempt, by nothing at
1189
+ // all. Reuse the retry event so the transcript says what happened.
1190
+ // Emitted after the bail check, so the count only ever describes
1191
+ // retries that actually happen.
1192
+ this.emit('retry', {
1193
+ attempt: emptyTurnCount,
1194
+ maxAttempts: MAX_EMPTY_TURNS - 1,
1195
+ delayMs: 0,
1196
+ reason: 'Model returned an empty response',
1197
+ });
935
1198
  continue;
936
1199
  }
937
1200
  emptyTurnCount = 0;
@@ -1063,6 +1326,26 @@ export class Agent extends EventEmitter {
1063
1326
  const args = tool?.source === 'scalix-cloud'
1064
1327
  ? parsedArgs
1065
1328
  : normalizeArgs(parsedArgs);
1329
+ // Validate against the very schema the model was shown. Nothing
1330
+ // checked this before, so a hallucinated parameter name reached
1331
+ // the tool body and came back as a Node internals message —
1332
+ // read_file called with {path} instead of {filePath} answered
1333
+ // 'The "paths[1]" argument must be of type string. Received
1334
+ // undefined', which tells the model nothing it can act on. Args
1335
+ // are passed through unchanged on success rather than swapped
1336
+ // for the parsed copy, since z.object() strips unknown keys and
1337
+ // some tools read fields their schema doesn't declare.
1338
+ const schema = tool?.function?.inputSchema ?? tool?.inputSchema;
1339
+ if (schema && typeof schema.safeParse === 'function') {
1340
+ const check = schema.safeParse(args);
1341
+ if (!check.success) {
1342
+ const issues = (check.error?.issues ?? [])
1343
+ .slice(0, 4)
1344
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
1345
+ .join('; ');
1346
+ throw new Error(`Invalid arguments for ${call.name} — ${issues || 'schema validation failed'}`);
1347
+ }
1348
+ }
1066
1349
  reqLog.debug('Executing tool', { tool: call.name });
1067
1350
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
1068
1351
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
@@ -1236,11 +1519,21 @@ export class Agent extends EventEmitter {
1236
1519
  if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
1237
1520
  retryAttempt++;
1238
1521
  forceNonStreaming = false;
1239
- const delay = backoffDelay(retryAttempt);
1522
+ // Honor the server's Retry-After on 429/503 (capped at 60s) instead
1523
+ // of a blind exponential guess; fall back to backoff otherwise.
1524
+ const serverWait = err instanceof ApiError ? err.retryAfterMs : undefined;
1525
+ const delay = serverWait !== undefined ? Math.min(serverWait, 60_000) : backoffDelay(retryAttempt);
1240
1526
  reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
1241
1527
  model: currentModel,
1242
1528
  error: err.message,
1243
1529
  });
1530
+ this.emit('retry', {
1531
+ attempt: retryAttempt,
1532
+ maxAttempts: this.config.maxRetries,
1533
+ delayMs: Math.round(delay),
1534
+ status: err instanceof ApiError ? err.status : undefined,
1535
+ reason: err.message,
1536
+ });
1244
1537
  await sleep(delay);
1245
1538
  currentModel = this.pickModel();
1246
1539
  continue;