scalix 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { ApiError } from './api-client.js';
2
2
  import { EventEmitter } from 'eventemitter3';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { getToolPermissionWithArgs } from './sandbox.js';
4
+ import { getToolPermissionWithArgs, getMaxImageSize, sandboxPathAsync } 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, CONTEXT_DEFAULTS, SCALIX_PLATFORM_SECTION, SCALIX_CLOUD_RESOURCES_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.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_DEPLOY_PLAYBOOK_SECTION, SCALIX_PLATFORM_PLAYBOOK_SECTION, SCALIX_NEUTRAL_POINTER, SCALIX_STEERING_ALWAYS_ON, modelSupportsVision, getModelSpec, maxOutputTokensForRequest, hasExplicitModelSpec } from './constants.js';
7
7
  import { estimateMessageTokens as estimateTokenCount, estimateTokens } from './token-counter.js';
8
8
  import { ContextManager } from './context-manager.js';
9
9
  import { zodToJsonSchema } from './tool-helper.js';
10
10
  import { ToolSelector } from './tool-selector.js';
11
11
  import { AgentReplay } from './agent-replay.js';
12
12
  import { loadHooks, runHooks } from './hooks.js';
13
+ import { isWorkspaceTrusted } from './workspace-trust.js';
13
14
  import { setAgentToolConfig } from './tools-advanced.js';
15
+ import { setCommandSandboxMode } from './tools.js';
16
+ import { resolveCommandSandboxMode } from './command-sandbox.js';
14
17
  import { AgentRegistry, generateAgentId } from './agent-registry.js';
18
+ import { getPreset } from './agent-presets.js';
15
19
  import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
16
20
  import { ChangeTracker } from './change-tracker.js';
17
21
  import { detectPromptInjection, formatInjectionWarning } from './injection-detector.js';
@@ -21,6 +25,26 @@ import fs from 'node:fs/promises';
21
25
  import path from 'node:path';
22
26
  import { fetchCloudTools, callCloudTool, fetchMe, filterCatalogByScopes, buildCloudToolsPromptSection, cloudKeyRemedy } from './cloud.js';
23
27
  import { createChatClient, capabilitiesFor } from './providers/index.js';
28
+ import { McpHub } from './mcp.js';
29
+ import { BoardTurnRefresher } from './board-context.js';
30
+ const SUPPORTED_PASTED_IMAGE_MIMES = new Set([
31
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml',
32
+ ]);
33
+ export function validatePastedImages(images) {
34
+ const maxSize = getMaxImageSize();
35
+ for (const image of images) {
36
+ const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\s]+)$/i.exec(image);
37
+ if (!match || !SUPPORTED_PASTED_IMAGE_MIMES.has(match[1].toLowerCase())) {
38
+ throw new Error('Unsupported pasted image. Use PNG, JPEG, GIF, WebP, BMP, or SVG.');
39
+ }
40
+ const payload = match[2].replace(/\s/g, '');
41
+ const bytes = Math.floor(payload.length * 3 / 4)
42
+ - (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0);
43
+ if (bytes > maxSize) {
44
+ throw new Error(`Pasted image is too large (${Math.ceil(bytes / 1024 / 1024)}MB). Maximum is ${Math.floor(maxSize / 1024 / 1024)}MB.`);
45
+ }
46
+ }
47
+ }
24
48
  /**
25
49
  * Marks an error that Agent has already announced through the 'error' event.
26
50
  * `send()` emits and then rethrows, so a caller that also prints from its catch
@@ -30,11 +54,10 @@ import { createChatClient, capabilitiesFor } from './providers/index.js';
30
54
  export const REPORTED_ERROR = Symbol.for('scalix.coder.reportedError');
31
55
  function transformToolsForApi(tools) {
32
56
  return tools
33
- // type:'mcp' entries are decorative config for user-registered external
34
- // MCP servers and never reach the API. Scalix Cloud platform tools bypass
35
- // this path entirely: they are plain function tools (CloudPlatformTool,
36
- // registered by refreshCloudTools) whose `parameters` pass through as-is.
37
- .filter((t) => !('type' in t && t.type === 'mcp'))
57
+ // Everything here is a function tool. Remotely-backed ones — Scalix Cloud
58
+ // platform tools and MCP tool proxies — carry a ready-made JSON Schema in
59
+ // `parameters` that passes through as-is; local tools carry a Zod
60
+ // `inputSchema` that gets converted below.
38
61
  .map((t) => {
39
62
  const local = t;
40
63
  const fn = local.function ?? local;
@@ -149,9 +172,18 @@ export class Agent extends EventEmitter {
149
172
  configuredContextTokens;
150
173
  toolSelector = null;
151
174
  _aborted = false;
175
+ // Resolved when abort() fires — lets in-flight sleeps (retry backoff, tool
176
+ // confirmation) return immediately instead of ignoring cancellation.
177
+ _abortWaiters = new Set();
152
178
  _thinkingActive = false;
179
+ // Reasoning text accumulated across the current turn (all model calls) —
180
+ // what "what is the agent thinking" renders from.
181
+ _reasoningText = '';
153
182
  replay;
183
+ boardRefresher;
184
+ boardPrimed;
154
185
  hooks = [];
186
+ hooksSkipped = 0;
155
187
  instructionLoader;
156
188
  _abortController = null;
157
189
  modelFailures = new Map();
@@ -175,6 +207,7 @@ export class Agent extends EventEmitter {
175
207
  outputFormatInstructionIndex = null;
176
208
  cloudProjectId;
177
209
  cloudStatus = { state: 'no-key', toolCount: 0 };
210
+ mcpHub = null;
178
211
  providerId;
179
212
  capabilities;
180
213
  cloudApiKey;
@@ -225,6 +258,11 @@ export class Agent extends EventEmitter {
225
258
  workspaceRoot: config.workspaceRoot ?? process.cwd(),
226
259
  };
227
260
  this.cloudProjectId = config.cloudProjectId ?? process.env.SCALIX_PROJECT ?? undefined;
261
+ // OS-level command sandbox: the tool module is process-global, so every
262
+ // front end (CLI, IPC sessions, headless) inherits the mode the moment an
263
+ // agent is constructed. SCALIX_COMMAND_SANDBOX overrides the config value.
264
+ // Per-session override is deliberate — this is machine policy, not chat state.
265
+ setCommandSandboxMode(resolveCommandSandboxMode(config.commandSandbox, process.env.SCALIX_COMMAND_SANDBOX));
228
266
  this.contextManager = new ContextManager(config.contextManager);
229
267
  // Kept separately because syncContextLimit() overwrites the manager's own
230
268
  // value on every read and would otherwise have nothing to fall back to.
@@ -233,8 +271,28 @@ export class Agent extends EventEmitter {
233
271
  this.toolSelector = new ToolSelector();
234
272
  this.toolSelector.index(this.config.tools);
235
273
  }
274
+ // Crew board freshness (board-context.ts): baseline taken here, right
275
+ // after the system prompt embedded the board, so the first turnNote()
276
+ // only fires on real change. Fire-and-forget; send() awaits it.
277
+ this.boardRefresher = new BoardTurnRefresher(this.config.workspaceRoot ?? process.cwd());
278
+ this.boardPrimed = this.boardRefresher.prime().catch(() => { });
236
279
  this.replay = new AgentReplay(randomUUID().slice(0, 8), this.config.models[0]);
237
- this.hooks = loadHooks();
280
+ // Hooks are shell commands read out of the project's own .scalix/hooks.json
281
+ // — exec surface, exactly like plugins and scoped rules, and named as such
282
+ // in workspace-trust.ts's own docstring. The gate existed; this call site
283
+ // just never asked. Parsing the file while untrusted is still fine and is
284
+ // what lets us say "3 hooks were skipped" instead of going quietly inert.
285
+ const projectHooks = loadHooks();
286
+ if (isWorkspaceTrusted()) {
287
+ this.hooks = projectHooks;
288
+ }
289
+ else {
290
+ this.hooks = [];
291
+ this.hooksSkipped = projectHooks.length;
292
+ if (projectHooks.length > 0) {
293
+ this.log.warn('Project hooks skipped — untrusted workspace', { count: projectHooks.length });
294
+ }
295
+ }
238
296
  this.instructionLoader = config.instructionLoader;
239
297
  if (config.autoVerify === true) {
240
298
  this.verifyConfig = { ...VERIFY_DEFAULTS };
@@ -354,6 +412,16 @@ export class Agent extends EventEmitter {
354
412
  message.includes('stream error') ||
355
413
  message.includes('try again'));
356
414
  }
415
+ setPreset(presetId) {
416
+ const preset = getPreset(presetId);
417
+ if (!preset)
418
+ throw new Error(`Unknown preset: ${presetId}`);
419
+ this.config.permissionMode = preset.permissionMode;
420
+ // We append the prompt to the base instructions
421
+ if (preset.systemPromptAdditions && !this.config.instructions.includes(preset.systemPromptAdditions)) {
422
+ this.config.instructions += '\n\n' + preset.systemPromptAdditions;
423
+ }
424
+ }
357
425
  isModelCircuitOpen(model) {
358
426
  const state = this.modelFailures.get(model);
359
427
  if (!state)
@@ -396,8 +464,14 @@ export class Agent extends EventEmitter {
396
464
  this.messages = [];
397
465
  this.verifyFailCounts.clear();
398
466
  this.modelFailures.clear();
467
+ // The old index points into the discarded history — keeping it would make
468
+ // the next addOutputFormatInstructions() overwrite an unrelated message.
469
+ this.outputFormatInstructionIndex = null;
470
+ }
471
+ loadMessages(messages) {
472
+ this.messages = [...messages];
473
+ this.outputFormatInstructionIndex = null;
399
474
  }
400
- loadMessages(messages) { this.messages = [...messages]; }
401
475
  getInstructions() { return this.config.instructions; }
402
476
  setInstructions(instructions) { this.config.instructions = instructions; }
403
477
  getPermissionMode() { return this.config.permissionMode; }
@@ -580,6 +654,64 @@ export class Agent extends EventEmitter {
580
654
  if (this.toolSelector)
581
655
  this.toolSelector.index(this.config.tools);
582
656
  }
657
+ // ─── MCP servers ───
658
+ isMcpTool(t) {
659
+ return 'source' in t && t.source === 'mcp';
660
+ }
661
+ /**
662
+ * Take ownership of a connected hub and register its tools. The entrypoint
663
+ * connects (so a slow server delays startup, not the first message) and hands
664
+ * the hub over; the agent needs it so `add_mcp_server` can connect at runtime
665
+ * and so `close()` can shut the child processes down.
666
+ */
667
+ attachMcpHub(hub) {
668
+ this.mcpHub = hub;
669
+ this.syncMcpTools();
670
+ }
671
+ /** Replace the registered MCP proxies with whatever the hub currently holds. */
672
+ syncMcpTools() {
673
+ this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
674
+ if (this.mcpHub) {
675
+ const taken = new Set(this.config.tools.map((t) => t.function?.name || t.name).filter(Boolean));
676
+ for (const t of this.mcpHub.tools()) {
677
+ // A namespaced mcp__ name colliding with a local tool means two servers
678
+ // share a label; keep the first and say so rather than shadowing.
679
+ if (taken.has(t.name)) {
680
+ this.log.warn('MCP tool name already taken — skipping', { tool: t.name, server: t.serverLabel });
681
+ continue;
682
+ }
683
+ taken.add(t.name);
684
+ this.config.tools.push(t);
685
+ }
686
+ }
687
+ if (this.toolSelector)
688
+ this.toolSelector.index(this.config.tools);
689
+ }
690
+ /**
691
+ * Connect one server and register its tools immediately. Creates the hub if
692
+ * the entrypoint never attached one, so a runtime `add_mcp_server` works even
693
+ * when no config file existed at startup.
694
+ */
695
+ async connectMcpServer(config) {
696
+ if (!this.mcpHub)
697
+ this.mcpHub = new McpHub();
698
+ const [status] = await this.mcpHub.connect([config]);
699
+ this.syncMcpTools();
700
+ return status;
701
+ }
702
+ getMcpStatus() {
703
+ return this.mcpHub?.status() ?? [];
704
+ }
705
+ getMcpTools() {
706
+ return this.config.tools.filter((t) => this.isMcpTool(t));
707
+ }
708
+ /** Disconnect every MCP server. Call on shutdown — stdio servers are children. */
709
+ async closeMcp() {
710
+ const hub = this.mcpHub;
711
+ this.mcpHub = null;
712
+ this.config.tools = this.config.tools.filter((t) => !this.isMcpTool(t));
713
+ await hub?.close();
714
+ }
583
715
  // ─── Scalix Cloud platform tools ───
584
716
  isCloudTool(t) {
585
717
  return 'source' in t && t.source === 'scalix-cloud';
@@ -731,6 +863,20 @@ export class Agent extends EventEmitter {
731
863
  getReplay() { return this.replay; }
732
864
  getChangeTracker() { return this.changeTracker; }
733
865
  getHooks() { return this.hooks; }
866
+ /** How many project hooks the trust gate withheld. 0 when trusted. */
867
+ getSkippedHookCount() { return this.hooksSkipped; }
868
+ /**
869
+ * Re-read project hooks after the user trusts the workspace, so /trust picks
870
+ * them up in the same session rather than only after a restart. Returns the
871
+ * number now active.
872
+ */
873
+ reloadHooks() {
874
+ if (!isWorkspaceTrusted())
875
+ return 0;
876
+ this.hooks = loadHooks();
877
+ this.hooksSkipped = 0;
878
+ return this.hooks.length;
879
+ }
734
880
  getApiKey() { return this.config.apiKey; }
735
881
  getBaseUrl() { return this.client.baseURL; }
736
882
  getAgentId() { return this.agentId; }
@@ -773,6 +919,12 @@ export class Agent extends EventEmitter {
773
919
  return {
774
920
  apiKey: this.config.apiKey,
775
921
  baseUrl: this.client.baseURL,
922
+ // Sub-agents must inherit the provider itself, not just its URL: without
923
+ // it a BYOK session's sub-agent would default to provider 'scalix' while
924
+ // keeping the third-party baseUrl and 404 on <byok-host>/v1/ai/...
925
+ provider: this.providerId,
926
+ cloudApiKey: this.cloudApiKey,
927
+ cloudBaseUrl: this.cloudBaseUrl,
776
928
  models: this.config.models,
777
929
  permissionMode: this.config.permissionMode,
778
930
  confirmToolCall: this.config.confirmToolCall,
@@ -784,8 +936,26 @@ export class Agent extends EventEmitter {
784
936
  this._abortController.abort();
785
937
  this._abortController = null;
786
938
  }
939
+ const waiters = [...this._abortWaiters];
940
+ this._abortWaiters.clear();
941
+ for (const w of waiters)
942
+ w();
787
943
  this.emitThinkingEnd();
788
944
  }
945
+ /**
946
+ * Sleep that returns early when abort() fires. Unlike a bare setTimeout,
947
+ * cancellation during retry backoff or a confirmation wait is observed
948
+ * immediately rather than after the full delay.
949
+ */
950
+ abortableSleep(ms) {
951
+ if (this._aborted)
952
+ return Promise.resolve();
953
+ return new Promise((resolve) => {
954
+ const waiter = () => { clearTimeout(timer); resolve(); };
955
+ const timer = setTimeout(() => { this._abortWaiters.delete(waiter); resolve(); }, ms);
956
+ this._abortWaiters.add(waiter);
957
+ });
958
+ }
789
959
  removeModel(model) {
790
960
  const idx = this.config.models.indexOf(model);
791
961
  if (idx !== -1 && this.config.models.length > 1) {
@@ -808,7 +978,11 @@ export class Agent extends EventEmitter {
808
978
  const imageParts = [];
809
979
  for (const filePath of matches) {
810
980
  try {
811
- const resolved = path.resolve(process.cwd(), filePath);
981
+ // @file mentions expand file contents into the prompt — route them
982
+ // through the same containment as read_file, and resolve against the
983
+ // configured workspace root (not necessarily process.cwd()).
984
+ const root = this.config.workspaceRoot ?? process.cwd();
985
+ const resolved = await sandboxPathAsync(path.resolve(root, filePath));
812
986
  if (isImageFile(filePath)) {
813
987
  const dataUrl = await encodeImageToDataUrl(resolved);
814
988
  imageParts.push({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } });
@@ -903,6 +1077,8 @@ export class Agent extends EventEmitter {
903
1077
  async send(content, images) {
904
1078
  this._aborted = false;
905
1079
  this.groundingNudgeCount = 0;
1080
+ if (images?.length)
1081
+ validatePastedImages(images);
906
1082
  const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
907
1083
  const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
908
1084
  let finalContent = expandedContent;
@@ -922,9 +1098,28 @@ export class Agent extends EventEmitter {
922
1098
  this.messages.push(userMessage);
923
1099
  this.emit('message:user', userMessage);
924
1100
  this._thinkingActive = true;
1101
+ this._reasoningText = '';
925
1102
  this.emit('thinking:start');
926
1103
  this.truncateMessages();
927
1104
  this.replay.record('user_message', { content });
1105
+ // Crew board turn-refresh: the system prompt is assembled once per
1106
+ // session, so a board that moved since then would go stale mid-thread.
1107
+ // Runs AFTER the push — send()'s synchronous contract (user message in
1108
+ // history and message:user emitted before the first await) is what the
1109
+ // IDE paints on — with the note spliced in BEFORE the user message, so
1110
+ // the member still reads the news before the ask. One file read per
1111
+ // turn; a workspace with no BOARD.md never produces a note.
1112
+ try {
1113
+ await this.boardPrimed;
1114
+ const boardNote = await this.boardRefresher.turnNote();
1115
+ if (boardNote) {
1116
+ const boardMessage = { role: 'system', content: boardNote };
1117
+ const idx = this.messages.lastIndexOf(userMessage);
1118
+ this.messages.splice(idx < 0 ? this.messages.length : idx, 0, boardMessage);
1119
+ this.replay.record('board_refresh', { chars: boardNote.length });
1120
+ }
1121
+ }
1122
+ catch { }
928
1123
  const requestId = randomUUID().slice(0, 8);
929
1124
  const reqLog = logger.child(requestId);
930
1125
  reqLog.info('User message received', { length: content.length });
@@ -949,7 +1144,7 @@ export class Agent extends EventEmitter {
949
1144
  // platform key is a dead end that doesn't touch the credential that failed.
950
1145
  if (status === 401 && !error.message.includes('console.scalix.world') && !error.message.includes('/setup')) {
951
1146
  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.'
1147
+ ? ' — API key rejected. Create a new key at https://console.scalix.world/org/keys?utm_source=scalix-coder&utm_medium=cli and update it via /setup.'
953
1148
  : ` — API key rejected by ${this.client.baseURL}. Check the key for your provider and update it via /setup.`;
954
1149
  }
955
1150
  error.metadata = {
@@ -974,6 +1169,13 @@ export class Agent extends EventEmitter {
974
1169
  let emptyTurnCount = 0;
975
1170
  const MAX_EMPTY_TURNS = 5;
976
1171
  let disableTools = false;
1172
+ let _stepIndex = 0;
1173
+ let _turnToolDurationMs = 0;
1174
+ let _turnLlmStartMs = 0;
1175
+ let _turnTtftMs = 0;
1176
+ let _firstDeltaSeen = false;
1177
+ let _turnInputTokens = 0;
1178
+ let _turnOutputTokens = 0;
977
1179
  let forceNonStreaming = false;
978
1180
  while (turnCount < this.config.maxSteps) {
979
1181
  if (this._aborted) {
@@ -983,6 +1185,15 @@ export class Agent extends EventEmitter {
983
1185
  }
984
1186
  this.checkInbox();
985
1187
  turnCount++;
1188
+ // Per-turn metrics reset here so streaming TTFT (and token/duration
1189
+ // counters) reflect the turn that actually produced the final response,
1190
+ // not the first turn of the request.
1191
+ _turnToolDurationMs = 0;
1192
+ _turnLlmStartMs = 0;
1193
+ _turnTtftMs = 0;
1194
+ _firstDeltaSeen = false;
1195
+ _turnInputTokens = 0;
1196
+ _turnOutputTokens = 0;
986
1197
  let fullText = '';
987
1198
  const toolCalls = new Map();
988
1199
  try {
@@ -1039,6 +1250,9 @@ export class Agent extends EventEmitter {
1039
1250
  if (!SCALIX_STEERING_ALWAYS_ON) {
1040
1251
  effectiveInstructions += '\n\n' + SCALIX_PLATFORM_SECTION + '\n\n' + SCALIX_CLOUD_RESOURCES_SECTION;
1041
1252
  }
1253
+ // Deploy-path and platform facts ride with the connection too —
1254
+ // outside the ALWAYS_ON guard so flipping that flag never drops them.
1255
+ effectiveInstructions += '\n\n' + SCALIX_DEPLOY_PLAYBOOK_SECTION + '\n\n' + SCALIX_PLATFORM_PLAYBOOK_SECTION;
1042
1256
  const liveCloudTools = this.getCloudTools();
1043
1257
  if (liveCloudTools.length > 0) {
1044
1258
  effectiveInstructions += '\n\n' + buildCloudToolsPromptSection(liveCloudTools);
@@ -1054,11 +1268,14 @@ export class Agent extends EventEmitter {
1054
1268
  // An endpoint that does not carry tool calls over SSE would answer an
1055
1269
  // agentic turn in prose and lose the calls, so those turns go
1056
1270
  // 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()
1271
+ // Text-only turns stream either way. The probe must never fail a turn:
1272
+ // any error defaults to the conservative non-streaming answer.
1273
+ const streams = await this.streamsToolCalls().catch(() => false);
1274
+ const useStreaming = !forceNonStreaming && (streams
1059
1275
  ? true
1060
1276
  : (!apiTools || apiTools.length === 0) && !hasToolMessages);
1061
1277
  if (useStreaming) {
1278
+ _turnLlmStartMs = Date.now();
1062
1279
  const stream = await this.client.chatComplete({
1063
1280
  model: currentModel,
1064
1281
  messages: apiMessages,
@@ -1078,9 +1295,27 @@ export class Agent extends EventEmitter {
1078
1295
  if (choice?.delta) {
1079
1296
  const delta = choice.delta;
1080
1297
  if (delta.content) {
1298
+ if (!_firstDeltaSeen) {
1299
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1300
+ _firstDeltaSeen = true;
1301
+ }
1081
1302
  fullText += delta.content;
1082
1303
  this.emit('stream:delta', delta.content, fullText);
1083
1304
  }
1305
+ // Reasoning deltas (deepseek-reasoner, glm thinking models,
1306
+ // OpenRouter :thinking, the Scalix router's thinking_level) were
1307
+ // dropped — the "what is the agent thinking" display streams
1308
+ // from these. Some models emit reasoning unconditionally; when
1309
+ // it arrives after content started, re-open the thinking phase.
1310
+ const reasoningPiece = delta.reasoning_content ?? delta.reasoning ?? null;
1311
+ if (reasoningPiece) {
1312
+ if (!this._thinkingActive) {
1313
+ this._thinkingActive = true;
1314
+ this.emit('thinking:start');
1315
+ }
1316
+ this._reasoningText += reasoningPiece;
1317
+ this.emit('reasoning:update', this._reasoningText);
1318
+ }
1084
1319
  if (delta.tool_calls) {
1085
1320
  for (const tc of delta.tool_calls) {
1086
1321
  const idx = tc.index ?? toolCalls.size;
@@ -1101,6 +1336,8 @@ export class Agent extends EventEmitter {
1101
1336
  const inTok = chunk.usage.prompt_tokens ?? 0;
1102
1337
  const outTok = chunk.usage.completion_tokens ?? 0;
1103
1338
  const totalTok = chunk.usage.total_tokens ?? (inTok + outTok);
1339
+ _turnInputTokens = inTok;
1340
+ _turnOutputTokens = outTok;
1104
1341
  const cost = chunk.usage.cost ?? 0;
1105
1342
  this.emit('usage', {
1106
1343
  prompt_tokens: inTok,
@@ -1114,6 +1351,7 @@ export class Agent extends EventEmitter {
1114
1351
  }
1115
1352
  }
1116
1353
  else {
1354
+ _turnLlmStartMs = Date.now();
1117
1355
  this.emit('stream:start');
1118
1356
  const result = await this.client.chatComplete({
1119
1357
  model: currentModel,
@@ -1128,10 +1366,18 @@ export class Agent extends EventEmitter {
1128
1366
  this.recordModelSuccess(currentModel);
1129
1367
  const choice = result.choices?.[0];
1130
1368
  if (choice?.message) {
1369
+ _turnTtftMs = Date.now() - _turnLlmStartMs;
1370
+ _firstDeltaSeen = true;
1131
1371
  if (choice.message.content) {
1132
1372
  fullText = choice.message.content;
1133
1373
  this.emit('stream:delta', fullText, fullText);
1134
1374
  }
1375
+ // Non-streaming path carries reasoning the same way.
1376
+ const msg = choice.message;
1377
+ if (msg.reasoning || msg.reasoning_content) {
1378
+ this._reasoningText += String(msg.reasoning_content ?? msg.reasoning ?? '');
1379
+ this.emit('reasoning:update', this._reasoningText);
1380
+ }
1135
1381
  if (choice.message.tool_calls) {
1136
1382
  for (const tc of choice.message.tool_calls) {
1137
1383
  toolCalls.set(toolCalls.size, {
@@ -1146,6 +1392,8 @@ export class Agent extends EventEmitter {
1146
1392
  const inTok = result.usage.prompt_tokens ?? 0;
1147
1393
  const outTok = result.usage.completion_tokens ?? 0;
1148
1394
  const totalTok = result.usage.total_tokens ?? (inTok + outTok);
1395
+ _turnInputTokens = inTok;
1396
+ _turnOutputTokens = outTok;
1149
1397
  const cost = result.usage.cost ?? 0;
1150
1398
  this.emit('usage', {
1151
1399
  prompt_tokens: inTok,
@@ -1212,10 +1460,16 @@ export class Agent extends EventEmitter {
1212
1460
  this.messages.push(assistantMessage);
1213
1461
  this.emit('message:assistant', assistantMessage);
1214
1462
  if (resolvedToolCalls.length > 0) {
1215
- for (const call of resolvedToolCalls) {
1463
+ // Multiple sub-agent spawns in one turn run concurrently — the
1464
+ // Kilo Code behavior for parallel Task calls. Every spawn is an
1465
+ // independent `agent` call, so sequencing them only adds wall-clock
1466
+ // time; any other tool mix stays sequential because write ordering
1467
+ // matters. Each call still goes through the same permission check,
1468
+ // confirmation, hooks and result plumbing as a solo call.
1469
+ const runCall = async (call) => {
1216
1470
  if (this._aborted) {
1217
1471
  reqLog.info('Agent aborted, skipping remaining tool calls');
1218
- break;
1472
+ return;
1219
1473
  }
1220
1474
  const tool = this.config.tools.find((t) => (t.function?.name || t.name) === call.name);
1221
1475
  let result;
@@ -1230,7 +1484,7 @@ export class Agent extends EventEmitter {
1230
1484
  this.emit('tool:blocked', call.name, 'Blocked by hook');
1231
1485
  this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
1232
1486
  this.emit('tool:result', call.name, result);
1233
- continue;
1487
+ return;
1234
1488
  }
1235
1489
  }
1236
1490
  // Parse tool arguments once, up front, so the permission decision,
@@ -1250,7 +1504,7 @@ export class Agent extends EventEmitter {
1250
1504
  this.emit('tool:blocked', call.name, 'Malformed arguments');
1251
1505
  this.messages.push({ role: 'tool', content: JSON.stringify(result), tool_call_id: call.id, name: call.name });
1252
1506
  this.emit('tool:result', call.name, result);
1253
- continue;
1507
+ return;
1254
1508
  }
1255
1509
  }
1256
1510
  // Args-aware permission so scoped allow/deny rules are enforced.
@@ -1266,7 +1520,7 @@ export class Agent extends EventEmitter {
1266
1520
  name: call.name,
1267
1521
  });
1268
1522
  this.emit('tool:result', call.name, result);
1269
- continue;
1523
+ return;
1270
1524
  }
1271
1525
  if (permission === 'ask') {
1272
1526
  if (!this.config.confirmToolCall) {
@@ -1284,24 +1538,36 @@ export class Agent extends EventEmitter {
1284
1538
  name: call.name,
1285
1539
  });
1286
1540
  this.emit('tool:result', call.name, result);
1287
- continue;
1541
+ return;
1288
1542
  }
1289
1543
  }
1290
1544
  else {
1291
1545
  const args = parsedArgs;
1292
1546
  const CONFIRM_TIMEOUT_MS = 300_000;
1293
1547
  const confirmed = await new Promise((resolve) => {
1294
- const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
1295
- this.config.confirmToolCall(call.name, args).then((v) => {
1548
+ let settled = false;
1549
+ const done = (v) => {
1550
+ if (settled)
1551
+ return;
1552
+ settled = true;
1296
1553
  clearTimeout(timer);
1554
+ this._abortWaiters.delete(waiter);
1297
1555
  resolve(v);
1556
+ };
1557
+ // An abort during the wait denies the tool immediately; the
1558
+ // loop's `if (this._aborted) break` below then exits —
1559
+ // otherwise the loop parks here for up to 5 minutes.
1560
+ const waiter = () => done(false);
1561
+ const timer = setTimeout(() => done(false), CONFIRM_TIMEOUT_MS);
1562
+ this._abortWaiters.add(waiter);
1563
+ this.config.confirmToolCall(call.name, args).then((v) => {
1564
+ done(v);
1298
1565
  }).catch(() => {
1299
- clearTimeout(timer);
1300
- resolve(false);
1566
+ done(false);
1301
1567
  });
1302
1568
  });
1303
1569
  if (this._aborted)
1304
- break;
1570
+ return;
1305
1571
  if (!confirmed) {
1306
1572
  result = { denied: true, message: `User denied execution of ${call.name}` };
1307
1573
  reqLog.info('Tool call denied by user', { tool: call.name });
@@ -1313,17 +1579,20 @@ export class Agent extends EventEmitter {
1313
1579
  name: call.name,
1314
1580
  });
1315
1581
  this.emit('tool:result', call.name, result);
1316
- continue;
1582
+ return;
1317
1583
  }
1318
1584
  }
1319
1585
  }
1320
1586
  const execFn = tool?.function?.execute ?? tool?.execute;
1321
1587
  if (tool && typeof execFn === 'function') {
1322
1588
  try {
1323
- // Cloud platform tools get the raw args: their JSON Schemas are
1324
- // authoritative (snake_case) and the gateway validates them —
1325
- // injecting camelCase duplicates could fail server-side validation.
1326
- const args = tool?.source === 'scalix-cloud'
1589
+ // Remotely-schema'd tools get the raw args: their JSON Schemas
1590
+ // are authoritative (snake_case) and the far side validates
1591
+ // them — injecting camelCase duplicates could fail that
1592
+ // validation. True for the Scalix Cloud gateway and equally for
1593
+ // any MCP server, which validates against its own inputSchema.
1594
+ const src = tool?.source;
1595
+ const args = (src === 'scalix-cloud' || src === 'mcp')
1327
1596
  ? parsedArgs
1328
1597
  : normalizeArgs(parsedArgs);
1329
1598
  // Validate against the very schema the model was shown. Nothing
@@ -1350,21 +1619,30 @@ export class Agent extends EventEmitter {
1350
1619
  const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
1351
1620
  ? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
1352
1621
  : this.config.toolTimeoutMs;
1622
+ const toolStart = Date.now();
1623
+ this.emit('step:start', { stepIndex: _stepIndex, name: call.name, args: parsedArgs, startedAt: toolStart });
1353
1624
  result = await withTimeout(execFn(args, {
1354
1625
  context: {},
1355
1626
  workspaceRoot: this.config.workspaceRoot ?? process.cwd(),
1356
1627
  permissionMode: this.config.permissionMode,
1357
1628
  agentId: this.agentId,
1358
- agentConfig: {
1359
- apiKey: this.config.apiKey,
1360
- baseUrl: this.client.baseURL,
1361
- models: this.config.models,
1362
- permissionMode: this.config.permissionMode,
1363
- confirmToolCall: this.config.confirmToolCall,
1364
- },
1629
+ agentConfig: this.getAgentConfig(),
1365
1630
  }), toolTimeout, call.name);
1631
+ const toolDurationMs = Date.now() - toolStart;
1632
+ _turnToolDurationMs += toolDurationMs;
1633
+ this.emit('step:done', { stepIndex: _stepIndex, name: call.name, durationMs: toolDurationMs, success: !result?.error });
1634
+ _stepIndex++;
1635
+ // The tool only persists the entry to mcp-services.json. Connect
1636
+ // it here too, so the server's tools are usable in this same
1637
+ // session instead of only after a restart — and report the
1638
+ // outcome back through the tool result, since a server that
1639
+ // won't answer is something the model needs to know now.
1366
1640
  if (call.name === 'add_mcp_server' && result?.success) {
1367
- this.addTool({ type: 'mcp', serverLabel: args.label, serverUrl: args.url });
1641
+ const connected = await this.connectMcpServer({
1642
+ serverLabel: args.label,
1643
+ serverUrl: args.url,
1644
+ });
1645
+ result = { ...result, connected: connected.state === 'connected', tools: connected.toolCount, ...(connected.error ? { connectError: connected.error } : {}) };
1368
1646
  }
1369
1647
  }
1370
1648
  catch (e) {
@@ -1373,11 +1651,19 @@ export class Agent extends EventEmitter {
1373
1651
  }
1374
1652
  }
1375
1653
  else {
1376
- const availableNames = this.config.tools
1654
+ const allNames = this.config.tools
1377
1655
  .map((t) => t.function?.name || t.name)
1378
- .filter(Boolean)
1379
- .slice(0, 20);
1380
- result = { error: `Tool "${call.name}" does not exist. Available tools: ${availableNames.join(', ')}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
1656
+ .filter(Boolean);
1657
+ // Near-matches first: an invented "scalix_build_list" must surface
1658
+ // the real scalix_* names. Blindly listing the first 20 registry
1659
+ // entries hid every platform tool and read as "those don't exist" —
1660
+ // the model then invented workarounds instead of correcting the name
1661
+ // (seen 2026-08-09: a deploy derailed into "make the repo public").
1662
+ const stem = call.name.toLowerCase().split(/[_-]/)[0];
1663
+ const near = stem.length >= 3 ? allNames.filter((n) => n.toLowerCase().includes(stem)) : [];
1664
+ const shown = [...new Set([...near, ...allNames])].slice(0, Math.max(20, Math.min(near.length, 40)));
1665
+ const omitted = allNames.length - shown.length;
1666
+ result = { error: `Tool "${call.name}" does not exist.${near.length ? ` Did you mean one of: ${near.slice(0, 40).join(', ')}?` : ''} Available tools: ${shown.join(', ')}${omitted > 0 ? `, plus ${omitted} more registered but not listed here` : ''}. Do not invent tool names — only use tools from the list above, or respond with text directly.` };
1381
1667
  }
1382
1668
  let resultStr;
1383
1669
  try {
@@ -1480,9 +1766,33 @@ export class Agent extends EventEmitter {
1480
1766
  }
1481
1767
  }
1482
1768
  }
1769
+ };
1770
+ if (resolvedToolCalls.length > 1 && resolvedToolCalls.every((c) => c.name === 'agent')) {
1771
+ await Promise.all(resolvedToolCalls.map((c) => runCall(c).catch((err) => {
1772
+ reqLog.error('Sub-agent spawn failed', { tool: c.name, error: err?.message });
1773
+ })));
1774
+ }
1775
+ else {
1776
+ for (const call of resolvedToolCalls) {
1777
+ if (this._aborted) {
1778
+ reqLog.info('Agent aborted, skipping remaining tool calls');
1779
+ break;
1780
+ }
1781
+ await runCall(call);
1782
+ }
1483
1783
  }
1484
1784
  if (this._aborted)
1485
1785
  return '[interrupted]';
1786
+ this.emit('turn:summary', {
1787
+ turnIndex: turnCount,
1788
+ steps: _stepIndex,
1789
+ llmDurationMs: Date.now() - _turnLlmStartMs,
1790
+ toolDurationMs: _turnToolDurationMs,
1791
+ ttftMs: _turnTtftMs,
1792
+ tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
1793
+ inputTokens: _turnInputTokens,
1794
+ outputTokens: _turnOutputTokens,
1795
+ });
1486
1796
  continue;
1487
1797
  }
1488
1798
  if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
@@ -1502,10 +1812,29 @@ export class Agent extends EventEmitter {
1502
1812
  }
1503
1813
  }
1504
1814
  }
1815
+ this.emit('turn:summary', {
1816
+ turnIndex: turnCount,
1817
+ steps: _stepIndex,
1818
+ llmDurationMs: Date.now() - _turnLlmStartMs,
1819
+ toolDurationMs: _turnToolDurationMs,
1820
+ ttftMs: _turnTtftMs,
1821
+ tokensPerSec: (Date.now() - _turnLlmStartMs) > 0 ? _turnOutputTokens / ((Date.now() - _turnLlmStartMs) / 1000) : 0,
1822
+ inputTokens: _turnInputTokens,
1823
+ outputTokens: _turnOutputTokens,
1824
+ });
1505
1825
  return fullText;
1506
1826
  }
1507
1827
  catch (err) {
1508
1828
  this._abortController = null;
1829
+ // A user-initiated abort is not a model failure — recording it would
1830
+ // open the circuit breaker after a few Escapes and silently rotate
1831
+ // the user's model. An abort mid-request mirrors the between-iteration
1832
+ // contract: deregister and return '[interrupted]'.
1833
+ if (this._aborted || err?.name === 'AbortError') {
1834
+ reqLog.info('Agent aborted during request');
1835
+ this.deregisterFromRegistry('aborted');
1836
+ return '[interrupted]';
1837
+ }
1509
1838
  this.recordModelFailure(currentModel);
1510
1839
  const errMsg = (err?.message || '').toLowerCase();
1511
1840
  const isStreamError = errMsg.includes('stream') || errMsg.includes('sse') || errMsg.includes('no response body');
@@ -1513,7 +1842,7 @@ export class Agent extends EventEmitter {
1513
1842
  if (isStreamError && !forceNonStreaming) {
1514
1843
  forceNonStreaming = true;
1515
1844
  reqLog.warn('Streaming failed, retrying with non-streaming mode', { error: err.message });
1516
- await sleep(500);
1845
+ await this.abortableSleep(500);
1517
1846
  continue;
1518
1847
  }
1519
1848
  if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
@@ -1534,7 +1863,7 @@ export class Agent extends EventEmitter {
1534
1863
  status: err instanceof ApiError ? err.status : undefined,
1535
1864
  reason: err.message,
1536
1865
  });
1537
- await sleep(delay);
1866
+ await this.abortableSleep(delay);
1538
1867
  currentModel = this.pickModel();
1539
1868
  continue;
1540
1869
  }