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/.env.example +5 -3
- package/CHANGELOG.md +40 -0
- package/LICENSE +0 -1
- package/README.md +91 -3
- package/dist/agent-presets.js +33 -0
- package/dist/agent.js +372 -43
- package/dist/api-client.js +64 -5
- package/dist/board-context.js +81 -0
- package/dist/browser-access.js +0 -0
- package/dist/cli.js +397 -45
- package/dist/cloud.js +1 -1
- package/dist/command-sandbox.js +146 -0
- package/dist/commands.js +18 -6
- package/dist/config.js +44 -5
- package/dist/constants.js +90 -11
- package/dist/context-builder.js +9 -1
- package/dist/cost-tracker.js +1 -1
- package/dist/feedback.js +51 -0
- package/dist/headless.js +43 -9
- package/dist/ipc-server.js +371 -110
- package/dist/keychain.js +34 -9
- package/dist/marketplace.js +400 -0
- package/dist/mcp.js +498 -29
- package/dist/openrouter-oauth.js +149 -0
- package/dist/project-index.js +46 -23
- package/dist/providers/index.js +21 -4
- package/dist/sandbox.js +115 -27
- package/dist/secure-fetch.js +117 -0
- package/dist/sessions.js +12 -0
- package/dist/settings.js +193 -44
- package/dist/slash-commands.js +8 -0
- package/dist/sub-agent.js +6 -2
- package/dist/tools-browser.js +95 -18
- package/dist/tools-extra.js +101 -7
- package/dist/tools-memory.js +74 -0
- package/dist/tools-web.js +92 -28
- package/dist/tools.js +99 -23
- package/dist/workspace-trust.js +11 -1
- package/dist/worktree.js +24 -5
- package/package.json +5 -3
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1295
|
-
|
|
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
|
-
|
|
1300
|
-
resolve(false);
|
|
1566
|
+
done(false);
|
|
1301
1567
|
});
|
|
1302
1568
|
});
|
|
1303
1569
|
if (this._aborted)
|
|
1304
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1324
|
-
// authoritative (snake_case) and the
|
|
1325
|
-
// injecting camelCase duplicates could fail
|
|
1326
|
-
|
|
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
|
-
|
|
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
|
|
1654
|
+
const allNames = this.config.tools
|
|
1377
1655
|
.map((t) => t.function?.name || t.name)
|
|
1378
|
-
.filter(Boolean)
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
|
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
|
|
1866
|
+
await this.abortableSleep(delay);
|
|
1538
1867
|
currentModel = this.pickModel();
|
|
1539
1868
|
continue;
|
|
1540
1869
|
}
|