scalix 1.0.0 → 1.2.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 +29 -0
- package/PARITY-PLAN.md +116 -0
- package/README.md +1 -1
- package/bin/scalix.js +1 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent.js +363 -74
- package/dist/api-client.js +77 -0
- package/dist/cli.js +724 -76
- package/dist/commands.js +53 -2
- package/dist/constants.js +43 -9
- package/dist/context-builder.js +8 -1
- package/dist/context-retriever.js +257 -0
- package/dist/git-context.js +32 -29
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +149 -20
- package/dist/hooks.js +148 -10
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +6 -2
- package/dist/keychain.js +109 -0
- package/dist/markdown.js +46 -3
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +22 -1
- package/dist/sandbox.js +201 -11
- package/dist/sessions.js +96 -2
- package/dist/settings.js +194 -8
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +83 -8
- package/dist/task-board.js +105 -0
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/tools-advanced.js +304 -49
- package/dist/tools-browser.js +280 -19
- package/dist/tools-extra.js +9 -8
- package/dist/tools-vision.js +4 -3
- package/dist/tools.js +13 -3
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/package.json +8 -3
- package/app.py +0 -55
package/dist/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ScalixClient } from './api-client.js';
|
|
2
2
|
import { EventEmitter } from 'eventemitter3';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { getToolPermission } from './sandbox.js';
|
|
@@ -11,8 +11,12 @@ import { ToolSelector } from './tool-selector.js';
|
|
|
11
11
|
import { AgentReplay } from './agent-replay.js';
|
|
12
12
|
import { loadHooks, runHooks } from './hooks.js';
|
|
13
13
|
import { setAgentToolConfig } from './tools-advanced.js';
|
|
14
|
+
import { AgentRegistry, generateAgentId } from './agent-registry.js';
|
|
14
15
|
import { runVerification, VERIFY_DEFAULTS } from './auto-verify.js';
|
|
15
16
|
import { ChangeTracker } from './change-tracker.js';
|
|
17
|
+
import { detectPromptInjection, formatInjectionWarning } from './injection-detector.js';
|
|
18
|
+
import { extractCodeClaims, checkGrounding, formatGroundingNudge, GROUNDING_DEFAULTS } from './grounding-checker.js';
|
|
19
|
+
import { RETRIEVAL_DEFAULTS } from './context-retriever.js';
|
|
16
20
|
import fs from 'node:fs/promises';
|
|
17
21
|
import path from 'node:path';
|
|
18
22
|
function transformToolsForApi(tools) {
|
|
@@ -140,12 +144,24 @@ export class Agent extends EventEmitter {
|
|
|
140
144
|
verifyConfig = null;
|
|
141
145
|
verifyFailCounts = new Map();
|
|
142
146
|
compressAttempts = 0;
|
|
147
|
+
agentId;
|
|
148
|
+
parentAgentId;
|
|
149
|
+
agentDescription;
|
|
150
|
+
registeredInRegistry = false;
|
|
151
|
+
injectionDetectionEnabled;
|
|
152
|
+
injectionSensitivity;
|
|
153
|
+
injectionCustomPatterns;
|
|
154
|
+
outputFormat;
|
|
155
|
+
outputSchema;
|
|
156
|
+
groundingConfig = null;
|
|
157
|
+
groundingNudgeCount = 0;
|
|
158
|
+
retrievalConfig = null;
|
|
143
159
|
constructor(config) {
|
|
144
160
|
super();
|
|
145
|
-
this.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
this.agentId = config.agentId ?? generateAgentId();
|
|
162
|
+
this.parentAgentId = config.parentAgentId ?? null;
|
|
163
|
+
this.agentDescription = config.agentDescription ?? 'main';
|
|
164
|
+
this.client = new ScalixClient(config.baseUrl || 'https://api.scalix.world/v1', config.apiKey);
|
|
149
165
|
this.log = logger.child();
|
|
150
166
|
const resolvedModels = Array.isArray(config.model)
|
|
151
167
|
? (config.model.length > 0 ? config.model : FALLBACK_MODELS)
|
|
@@ -191,6 +207,33 @@ export class Agent extends EventEmitter {
|
|
|
191
207
|
permissionMode: this.config.permissionMode,
|
|
192
208
|
confirmToolCall: this.config.confirmToolCall,
|
|
193
209
|
});
|
|
210
|
+
if (config.injectionDetection === false) {
|
|
211
|
+
this.injectionDetectionEnabled = false;
|
|
212
|
+
this.injectionSensitivity = 'medium';
|
|
213
|
+
}
|
|
214
|
+
else if (typeof config.injectionDetection === 'object') {
|
|
215
|
+
this.injectionDetectionEnabled = true;
|
|
216
|
+
this.injectionSensitivity = config.injectionDetection.sensitivity ?? 'medium';
|
|
217
|
+
this.injectionCustomPatterns = config.injectionDetection.customPatterns;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
this.injectionDetectionEnabled = true;
|
|
221
|
+
this.injectionSensitivity = 'medium';
|
|
222
|
+
}
|
|
223
|
+
this.outputFormat = config.outputFormat ?? 'text';
|
|
224
|
+
this.outputSchema = config.outputSchema;
|
|
225
|
+
if (config.groundingCheck === true) {
|
|
226
|
+
this.groundingConfig = { ...GROUNDING_DEFAULTS };
|
|
227
|
+
}
|
|
228
|
+
else if (config.groundingCheck && typeof config.groundingCheck === 'object') {
|
|
229
|
+
this.groundingConfig = { ...GROUNDING_DEFAULTS, ...config.groundingCheck };
|
|
230
|
+
}
|
|
231
|
+
if (config.contextRetrieval === true) {
|
|
232
|
+
this.retrievalConfig = { ...RETRIEVAL_DEFAULTS };
|
|
233
|
+
}
|
|
234
|
+
else if (config.contextRetrieval && typeof config.contextRetrieval === 'object') {
|
|
235
|
+
this.retrievalConfig = { ...RETRIEVAL_DEFAULTS, ...config.contextRetrieval };
|
|
236
|
+
}
|
|
194
237
|
}
|
|
195
238
|
pickModel() {
|
|
196
239
|
const startIdx = this.modelIndex;
|
|
@@ -284,7 +327,7 @@ export class Agent extends EventEmitter {
|
|
|
284
327
|
...this.messages.map((m) => ({ role: (m.role === 'tool' ? 'user' : m.role), content: messageContentToString(m.content) })),
|
|
285
328
|
{ role: 'user', content: summaryPrompt },
|
|
286
329
|
];
|
|
287
|
-
const result = await this.client.
|
|
330
|
+
const result = await this.client.chatComplete({
|
|
288
331
|
model,
|
|
289
332
|
messages: apiMessages,
|
|
290
333
|
stream: false,
|
|
@@ -336,7 +379,7 @@ export class Agent extends EventEmitter {
|
|
|
336
379
|
try {
|
|
337
380
|
const prompt = this.contextManager.buildCompressionPrompt(toCompress);
|
|
338
381
|
const model = this.config.models[0];
|
|
339
|
-
const result = await this.client.
|
|
382
|
+
const result = await this.client.chatComplete({
|
|
340
383
|
model,
|
|
341
384
|
messages: [
|
|
342
385
|
{ 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.' },
|
|
@@ -388,6 +431,48 @@ export class Agent extends EventEmitter {
|
|
|
388
431
|
getHooks() { return this.hooks; }
|
|
389
432
|
getApiKey() { return this.config.apiKey; }
|
|
390
433
|
getBaseUrl() { return this.client.baseURL; }
|
|
434
|
+
getAgentId() { return this.agentId; }
|
|
435
|
+
registerInRegistry() {
|
|
436
|
+
if (this.registeredInRegistry)
|
|
437
|
+
return;
|
|
438
|
+
this.registeredInRegistry = true;
|
|
439
|
+
const registry = AgentRegistry.getInstance();
|
|
440
|
+
registry.register(this.agentId, this, this.parentAgentId, {
|
|
441
|
+
description: this.agentDescription,
|
|
442
|
+
model: this.config.models[0],
|
|
443
|
+
});
|
|
444
|
+
this.on('usage', (data) => {
|
|
445
|
+
registry.recordTokens(this.agentId, data.total_tokens ?? 0);
|
|
446
|
+
});
|
|
447
|
+
this.on('tool:call', () => {
|
|
448
|
+
registry.recordStep(this.agentId);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
deregisterFromRegistry(status) {
|
|
452
|
+
if (!this.registeredInRegistry)
|
|
453
|
+
return;
|
|
454
|
+
AgentRegistry.getInstance().deregister(this.agentId, status);
|
|
455
|
+
}
|
|
456
|
+
checkInbox() {
|
|
457
|
+
const registry = AgentRegistry.getInstance();
|
|
458
|
+
const msgs = registry.getUnread(this.agentId);
|
|
459
|
+
for (const msg of msgs) {
|
|
460
|
+
this.messages.push({
|
|
461
|
+
role: 'system',
|
|
462
|
+
content: `[Message from agent ${msg.from.slice(0, 8)}]: ${msg.content}`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
registry.markRead(this.agentId);
|
|
466
|
+
}
|
|
467
|
+
getAgentConfig() {
|
|
468
|
+
return {
|
|
469
|
+
apiKey: this.config.apiKey,
|
|
470
|
+
baseUrl: this.client.baseURL,
|
|
471
|
+
models: this.config.models,
|
|
472
|
+
permissionMode: this.config.permissionMode,
|
|
473
|
+
confirmToolCall: this.config.confirmToolCall,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
391
476
|
abort() {
|
|
392
477
|
this._aborted = true;
|
|
393
478
|
if (this._abortController) {
|
|
@@ -447,8 +532,64 @@ export class Agent extends EventEmitter {
|
|
|
447
532
|
return content;
|
|
448
533
|
return contextBlocks.join('\n\n') + '\n\n' + content;
|
|
449
534
|
}
|
|
535
|
+
addOutputFormatInstructions() {
|
|
536
|
+
let instruction;
|
|
537
|
+
if (this.outputFormat === 'json-schema' && this.outputSchema) {
|
|
538
|
+
instruction = `IMPORTANT: Your final response MUST be valid JSON matching this schema:\n${JSON.stringify(this.outputSchema, null, 2)}\n\nDo not include any text outside the JSON object. No markdown code fences, no explanation — only the raw JSON.`;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
instruction = 'IMPORTANT: Your final response MUST be valid JSON. Do not include any text outside the JSON object. No markdown code fences, no explanation — only the raw JSON.';
|
|
542
|
+
}
|
|
543
|
+
this.messages.push({ role: 'system', content: instruction });
|
|
544
|
+
}
|
|
545
|
+
extractJsonFromResponse(response) {
|
|
546
|
+
const trimmed = response.trim();
|
|
547
|
+
// Try direct parse
|
|
548
|
+
try {
|
|
549
|
+
JSON.parse(trimmed);
|
|
550
|
+
return trimmed;
|
|
551
|
+
}
|
|
552
|
+
catch { }
|
|
553
|
+
// Try extracting from markdown code fence
|
|
554
|
+
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
555
|
+
if (fenceMatch) {
|
|
556
|
+
try {
|
|
557
|
+
JSON.parse(fenceMatch[1].trim());
|
|
558
|
+
return fenceMatch[1].trim();
|
|
559
|
+
}
|
|
560
|
+
catch { }
|
|
561
|
+
}
|
|
562
|
+
// Try finding first { to last }
|
|
563
|
+
const firstBrace = trimmed.indexOf('{');
|
|
564
|
+
const lastBrace = trimmed.lastIndexOf('}');
|
|
565
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
566
|
+
const candidate = trimmed.slice(firstBrace, lastBrace + 1);
|
|
567
|
+
try {
|
|
568
|
+
JSON.parse(candidate);
|
|
569
|
+
return candidate;
|
|
570
|
+
}
|
|
571
|
+
catch { }
|
|
572
|
+
}
|
|
573
|
+
// Try finding first [ to last ]
|
|
574
|
+
const firstBracket = trimmed.indexOf('[');
|
|
575
|
+
const lastBracket = trimmed.lastIndexOf(']');
|
|
576
|
+
if (firstBracket !== -1 && lastBracket > firstBracket) {
|
|
577
|
+
const candidate = trimmed.slice(firstBracket, lastBracket + 1);
|
|
578
|
+
try {
|
|
579
|
+
JSON.parse(candidate);
|
|
580
|
+
return candidate;
|
|
581
|
+
}
|
|
582
|
+
catch { }
|
|
583
|
+
}
|
|
584
|
+
// Return as-is if no valid JSON found
|
|
585
|
+
return response;
|
|
586
|
+
}
|
|
587
|
+
getOutputFormat() {
|
|
588
|
+
return this.outputFormat;
|
|
589
|
+
}
|
|
450
590
|
async send(content, images) {
|
|
451
591
|
this._aborted = false;
|
|
592
|
+
this.groundingNudgeCount = 0;
|
|
452
593
|
const hasFileRefs = /@(?:[\w./-]+\/)?[\w.-]+\.\w+/.test(content);
|
|
453
594
|
const expandedContent = hasFileRefs ? await this.expandFileReferences(content) : content;
|
|
454
595
|
let finalContent = expandedContent;
|
|
@@ -461,6 +602,9 @@ export class Agent extends EventEmitter {
|
|
|
461
602
|
const newImages = images.map(dataUrl => ({ type: 'image_url', image_url: { url: dataUrl, detail: 'auto' } }));
|
|
462
603
|
finalContent = [textPart, ...existingImages, ...newImages];
|
|
463
604
|
}
|
|
605
|
+
if (this.outputFormat !== 'text') {
|
|
606
|
+
this.addOutputFormatInstructions();
|
|
607
|
+
}
|
|
464
608
|
const userMessage = { role: 'user', content: finalContent };
|
|
465
609
|
this.messages.push(userMessage);
|
|
466
610
|
this.emit('message:user', userMessage);
|
|
@@ -471,12 +615,19 @@ export class Agent extends EventEmitter {
|
|
|
471
615
|
const requestId = randomUUID().slice(0, 8);
|
|
472
616
|
const reqLog = logger.child(requestId);
|
|
473
617
|
reqLog.info('User message received', { length: content.length });
|
|
618
|
+
this.registerInRegistry();
|
|
474
619
|
try {
|
|
475
620
|
const initialModel = this.pickModel();
|
|
476
|
-
reqLog.info('Starting agent loop', { model: initialModel });
|
|
477
|
-
|
|
621
|
+
reqLog.info('Starting agent loop', { model: initialModel, agentId: this.agentId });
|
|
622
|
+
let result = await this.loopStreamingTurns(initialModel, reqLog);
|
|
623
|
+
if (this.outputFormat !== 'text') {
|
|
624
|
+
result = this.extractJsonFromResponse(result);
|
|
625
|
+
}
|
|
626
|
+
this.deregisterFromRegistry('completed');
|
|
627
|
+
return result;
|
|
478
628
|
}
|
|
479
629
|
catch (err) {
|
|
630
|
+
this.deregisterFromRegistry('failed');
|
|
480
631
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
481
632
|
error.metadata = {
|
|
482
633
|
model: this.config.models[this.modelIndex % this.config.models.length],
|
|
@@ -497,11 +648,15 @@ export class Agent extends EventEmitter {
|
|
|
497
648
|
let turnCount = 0;
|
|
498
649
|
let retryAttempt = 0;
|
|
499
650
|
let emptyTurnCount = 0;
|
|
651
|
+
let disableTools = false;
|
|
652
|
+
let forceNonStreaming = false;
|
|
500
653
|
while (turnCount < this.config.maxSteps) {
|
|
501
654
|
if (this._aborted) {
|
|
502
655
|
reqLog.info('Agent aborted by user');
|
|
656
|
+
this.deregisterFromRegistry('aborted');
|
|
503
657
|
return '[interrupted]';
|
|
504
658
|
}
|
|
659
|
+
this.checkInbox();
|
|
505
660
|
turnCount++;
|
|
506
661
|
let fullText = '';
|
|
507
662
|
const toolCalls = new Map();
|
|
@@ -525,29 +680,44 @@ export class Agent extends EventEmitter {
|
|
|
525
680
|
toolsForRequest = this.toolSelector.select(messageContentToString(lastUserMsg.content), recentTools);
|
|
526
681
|
}
|
|
527
682
|
}
|
|
528
|
-
const apiTools = toolsForRequest.length > 0 ? transformToolsForApi(toolsForRequest) : undefined;
|
|
683
|
+
const apiTools = !disableTools && toolsForRequest.length > 0 ? transformToolsForApi(toolsForRequest) : undefined;
|
|
529
684
|
const estimatedTokens = estimateMessageTokens(this.messages);
|
|
530
685
|
this.emit('cost:estimate', { estimatedTokens, model: currentModel });
|
|
531
686
|
this._abortController = new AbortController();
|
|
687
|
+
let effectiveInstructions = this.config.instructions;
|
|
688
|
+
if (this.retrievalConfig?.enabled && turnCount === 1) {
|
|
689
|
+
const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
|
|
690
|
+
if (lastUserMsg?.content) {
|
|
691
|
+
const queryText = messageContentToString(lastUserMsg.content);
|
|
692
|
+
try {
|
|
693
|
+
const { buildQueryContext } = await import('./context-builder.js');
|
|
694
|
+
const contextSnippets = await buildQueryContext(queryText, process.cwd(), this.retrievalConfig);
|
|
695
|
+
if (contextSnippets) {
|
|
696
|
+
effectiveInstructions += '\n\n' + contextSnippets;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
reqLog.warn('Context retrieval failed', { error: err.message });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
532
704
|
const vision = modelSupportsVision(currentModel);
|
|
533
|
-
const apiMessages = messagesToApi(this.messages,
|
|
705
|
+
const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
|
|
534
706
|
const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
this.recordModelSuccess(currentModel);
|
|
550
|
-
try {
|
|
707
|
+
const hasToolMessages = this.messages.some(m => m.role === 'tool' || (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0));
|
|
708
|
+
const useStreaming = !hasToolMessages && !forceNonStreaming;
|
|
709
|
+
if (useStreaming) {
|
|
710
|
+
const stream = await this.client.chatComplete({
|
|
711
|
+
model: currentModel,
|
|
712
|
+
messages: apiMessages,
|
|
713
|
+
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
714
|
+
temperature: 0.7,
|
|
715
|
+
stream: true,
|
|
716
|
+
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
717
|
+
}, this._abortController.signal);
|
|
718
|
+
this.emit('stream:start');
|
|
719
|
+
retryAttempt = 0;
|
|
720
|
+
this.recordModelSuccess(currentModel);
|
|
551
721
|
for await (const chunk of stream) {
|
|
552
722
|
if (this._aborted)
|
|
553
723
|
break;
|
|
@@ -590,8 +760,48 @@ export class Agent extends EventEmitter {
|
|
|
590
760
|
}
|
|
591
761
|
}
|
|
592
762
|
}
|
|
593
|
-
|
|
594
|
-
|
|
763
|
+
else {
|
|
764
|
+
this.emit('stream:start');
|
|
765
|
+
const result = await this.client.chatComplete({
|
|
766
|
+
model: currentModel,
|
|
767
|
+
messages: apiMessages,
|
|
768
|
+
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
769
|
+
temperature: 0.7,
|
|
770
|
+
stream: false,
|
|
771
|
+
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
772
|
+
}, this._abortController.signal);
|
|
773
|
+
retryAttempt = 0;
|
|
774
|
+
this.recordModelSuccess(currentModel);
|
|
775
|
+
const choice = result.choices?.[0];
|
|
776
|
+
if (choice?.message) {
|
|
777
|
+
if (choice.message.content) {
|
|
778
|
+
fullText = choice.message.content;
|
|
779
|
+
this.emit('stream:delta', fullText, fullText);
|
|
780
|
+
}
|
|
781
|
+
if (choice.message.tool_calls) {
|
|
782
|
+
for (const tc of choice.message.tool_calls) {
|
|
783
|
+
toolCalls.set(toolCalls.size, {
|
|
784
|
+
id: tc.id,
|
|
785
|
+
name: tc.function.name,
|
|
786
|
+
arguments: tc.function.arguments,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (result.usage) {
|
|
792
|
+
const inTok = result.usage.prompt_tokens ?? 0;
|
|
793
|
+
const outTok = result.usage.completion_tokens ?? 0;
|
|
794
|
+
const totalTok = result.usage.total_tokens ?? (inTok + outTok);
|
|
795
|
+
const cost = result.usage.cost ?? 0;
|
|
796
|
+
this.emit('usage', {
|
|
797
|
+
prompt_tokens: inTok,
|
|
798
|
+
completion_tokens: outTok,
|
|
799
|
+
total_tokens: totalTok,
|
|
800
|
+
cost,
|
|
801
|
+
model: currentModel,
|
|
802
|
+
});
|
|
803
|
+
this.replay.recordUsage(totalTok, cost);
|
|
804
|
+
}
|
|
595
805
|
}
|
|
596
806
|
for (const [, call] of toolCalls) {
|
|
597
807
|
this.emit('tool:call', call.name, call.arguments);
|
|
@@ -600,11 +810,23 @@ export class Agent extends EventEmitter {
|
|
|
600
810
|
this.emit('stream:end', fullText);
|
|
601
811
|
this.replay.record('stream_end', { text: fullText.slice(0, 200) });
|
|
602
812
|
const resolvedToolCalls = Array.from(toolCalls.values());
|
|
813
|
+
// Normalize hallucinated tool name prefixes (e.g. "default_api.write_file" → "write_file")
|
|
814
|
+
for (const tc of resolvedToolCalls) {
|
|
815
|
+
if (tc.name.includes('.')) {
|
|
816
|
+
const normalized = tc.name.split('.').pop();
|
|
817
|
+
reqLog.warn('Normalized tool name', { original: tc.name, normalized });
|
|
818
|
+
tc.name = normalized;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
603
821
|
const hasToolCalls = resolvedToolCalls.length > 0;
|
|
604
822
|
if (!fullText && !hasToolCalls) {
|
|
605
823
|
emptyTurnCount++;
|
|
606
824
|
reqLog.warn('Empty assistant turn (no text, no tool calls) — skipping', { emptyTurns: emptyTurnCount });
|
|
607
|
-
if (emptyTurnCount >=
|
|
825
|
+
if (emptyTurnCount >= 2 && !disableTools) {
|
|
826
|
+
disableTools = true;
|
|
827
|
+
reqLog.warn('Disabling tools due to repeated empty turns — model may not support function calling');
|
|
828
|
+
}
|
|
829
|
+
if (emptyTurnCount >= 5) {
|
|
608
830
|
reqLog.error('Too many empty turns, aborting');
|
|
609
831
|
return '[no response]';
|
|
610
832
|
}
|
|
@@ -662,50 +884,57 @@ export class Agent extends EventEmitter {
|
|
|
662
884
|
}
|
|
663
885
|
if (permission === 'ask') {
|
|
664
886
|
if (!this.config.confirmToolCall) {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
catch {
|
|
682
|
-
args = call.arguments;
|
|
887
|
+
if (this.config.permissionMode === 'auto') {
|
|
888
|
+
reqLog.info('Auto-approving tool (no confirmation handler in auto mode)', { tool: call.name });
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
|
|
892
|
+
reqLog.warn('Tool requires confirmation but no confirmToolCall provided', { tool: call.name });
|
|
893
|
+
this.emit('tool:blocked', call.name, 'No confirmation handler');
|
|
894
|
+
this.messages.push({
|
|
895
|
+
role: 'tool',
|
|
896
|
+
content: JSON.stringify(result),
|
|
897
|
+
tool_call_id: call.id,
|
|
898
|
+
name: call.name,
|
|
899
|
+
});
|
|
900
|
+
this.emit('tool:result', call.name, result);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
683
903
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
role: 'tool',
|
|
703
|
-
content: JSON.stringify(result),
|
|
704
|
-
tool_call_id: call.id,
|
|
705
|
-
name: call.name,
|
|
904
|
+
else {
|
|
905
|
+
let args;
|
|
906
|
+
try {
|
|
907
|
+
args = JSON.parse(call.arguments);
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
args = call.arguments;
|
|
911
|
+
}
|
|
912
|
+
const CONFIRM_TIMEOUT_MS = 300_000;
|
|
913
|
+
const confirmed = await new Promise((resolve) => {
|
|
914
|
+
const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
|
|
915
|
+
this.config.confirmToolCall(call.name, args).then((v) => {
|
|
916
|
+
clearTimeout(timer);
|
|
917
|
+
resolve(v);
|
|
918
|
+
}).catch(() => {
|
|
919
|
+
clearTimeout(timer);
|
|
920
|
+
resolve(false);
|
|
921
|
+
});
|
|
706
922
|
});
|
|
707
|
-
this.
|
|
708
|
-
|
|
923
|
+
if (this._aborted)
|
|
924
|
+
break;
|
|
925
|
+
if (!confirmed) {
|
|
926
|
+
result = { denied: true, message: `User denied execution of ${call.name}` };
|
|
927
|
+
reqLog.info('Tool call denied by user', { tool: call.name });
|
|
928
|
+
this.emit('tool:blocked', call.name, 'User denied');
|
|
929
|
+
this.messages.push({
|
|
930
|
+
role: 'tool',
|
|
931
|
+
content: JSON.stringify(result),
|
|
932
|
+
tool_call_id: call.id,
|
|
933
|
+
name: call.name,
|
|
934
|
+
});
|
|
935
|
+
this.emit('tool:result', call.name, result);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
709
938
|
}
|
|
710
939
|
}
|
|
711
940
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
@@ -722,7 +951,19 @@ export class Agent extends EventEmitter {
|
|
|
722
951
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
723
952
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
724
953
|
: this.config.toolTimeoutMs;
|
|
725
|
-
result = await withTimeout(execFn(args, {
|
|
954
|
+
result = await withTimeout(execFn(args, {
|
|
955
|
+
context: {},
|
|
956
|
+
workspaceRoot: process.cwd(),
|
|
957
|
+
permissionMode: this.config.permissionMode,
|
|
958
|
+
agentId: this.agentId,
|
|
959
|
+
agentConfig: {
|
|
960
|
+
apiKey: this.config.apiKey,
|
|
961
|
+
baseUrl: this.client.baseURL,
|
|
962
|
+
models: this.config.models,
|
|
963
|
+
permissionMode: this.config.permissionMode,
|
|
964
|
+
confirmToolCall: this.config.confirmToolCall,
|
|
965
|
+
},
|
|
966
|
+
}), toolTimeout, call.name);
|
|
726
967
|
if (call.name === 'add_mcp_server' && result?.success) {
|
|
727
968
|
this.addTool({ type: 'mcp', serverLabel: args.label, serverUrl: args.url });
|
|
728
969
|
}
|
|
@@ -733,7 +974,11 @@ export class Agent extends EventEmitter {
|
|
|
733
974
|
}
|
|
734
975
|
}
|
|
735
976
|
else {
|
|
736
|
-
|
|
977
|
+
const availableNames = this.config.tools
|
|
978
|
+
.map((t) => t.function?.name || t.name)
|
|
979
|
+
.filter(Boolean)
|
|
980
|
+
.slice(0, 20);
|
|
981
|
+
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.` };
|
|
737
982
|
}
|
|
738
983
|
let resultStr;
|
|
739
984
|
try {
|
|
@@ -759,6 +1004,23 @@ export class Agent extends EventEmitter {
|
|
|
759
1004
|
if (resultStr.length > MAX_TOOL_RESULT_CHARS) {
|
|
760
1005
|
resultStr = resultStr.slice(0, MAX_TOOL_RESULT_CHARS) + `\n...[truncated — ${(resultStr.length - MAX_TOOL_RESULT_CHARS).toLocaleString()} chars removed]`;
|
|
761
1006
|
}
|
|
1007
|
+
if (this.injectionDetectionEnabled) {
|
|
1008
|
+
const injectionCheck = detectPromptInjection(resultStr, call.name, this.injectionSensitivity, this.injectionCustomPatterns);
|
|
1009
|
+
if (injectionCheck.detected) {
|
|
1010
|
+
this.emit('injection:detected', injectionCheck);
|
|
1011
|
+
if (injectionCheck.confidence === 'high') {
|
|
1012
|
+
resultStr = formatInjectionWarning(injectionCheck) + '\n\n' + resultStr;
|
|
1013
|
+
}
|
|
1014
|
+
else if (injectionCheck.confidence === 'medium') {
|
|
1015
|
+
resultStr = `[Note: content may contain injection patterns (${injectionCheck.patterns.join(', ')})]\n\n` + resultStr;
|
|
1016
|
+
}
|
|
1017
|
+
reqLog.warn('Prompt injection detected in tool result', {
|
|
1018
|
+
tool: call.name,
|
|
1019
|
+
confidence: injectionCheck.confidence,
|
|
1020
|
+
patterns: injectionCheck.patterns,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
762
1024
|
this.messages.push({
|
|
763
1025
|
role: 'tool',
|
|
764
1026
|
content: resultStr,
|
|
@@ -824,13 +1086,40 @@ export class Agent extends EventEmitter {
|
|
|
824
1086
|
return '[interrupted]';
|
|
825
1087
|
continue;
|
|
826
1088
|
}
|
|
1089
|
+
if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
|
|
1090
|
+
const claims = extractCodeClaims(fullText);
|
|
1091
|
+
if (claims.length >= this.groundingConfig.minClaimsToCheck) {
|
|
1092
|
+
const groundingResult = checkGrounding(claims, this.messages, this.groundingConfig.ungroundedThreshold, this.groundingConfig.minClaimsToCheck);
|
|
1093
|
+
if (groundingResult.needsVerification) {
|
|
1094
|
+
this.groundingNudgeCount++;
|
|
1095
|
+
this.emit('grounding:unverified', groundingResult);
|
|
1096
|
+
this.messages.push({ role: 'system', content: formatGroundingNudge(groundingResult) });
|
|
1097
|
+
reqLog.info('Grounding check: nudging agent to verify claims', {
|
|
1098
|
+
total: claims.length,
|
|
1099
|
+
ungrounded: groundingResult.ungroundedClaims.length,
|
|
1100
|
+
ratio: groundingResult.groundingRatio,
|
|
1101
|
+
});
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
827
1106
|
return fullText;
|
|
828
1107
|
}
|
|
829
1108
|
catch (err) {
|
|
830
1109
|
this._abortController = null;
|
|
831
1110
|
this.recordModelFailure(currentModel);
|
|
1111
|
+
const errMsg = (err?.message || '').toLowerCase();
|
|
1112
|
+
const isStreamError = errMsg.includes('stream') || errMsg.includes('sse') || errMsg.includes('no response body');
|
|
1113
|
+
// On stream failure, retry once with non-streaming before giving up
|
|
1114
|
+
if (isStreamError && !forceNonStreaming) {
|
|
1115
|
+
forceNonStreaming = true;
|
|
1116
|
+
reqLog.warn('Streaming failed, retrying with non-streaming mode', { error: err.message });
|
|
1117
|
+
await sleep(500);
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
832
1120
|
if (this.shouldFallbackToNext(err) && retryAttempt < this.config.maxRetries && turnCount < this.config.maxSteps) {
|
|
833
1121
|
retryAttempt++;
|
|
1122
|
+
forceNonStreaming = false;
|
|
834
1123
|
const delay = backoffDelay(retryAttempt);
|
|
835
1124
|
reqLog.warn(`Retrying after ${Math.round(delay)}ms (attempt ${retryAttempt}/${this.config.maxRetries})`, {
|
|
836
1125
|
model: currentModel,
|