scalix 1.0.0 → 1.1.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/README.md +1 -1
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent.js +352 -74
- package/dist/api-client.js +77 -0
- package/dist/cli.js +160 -35
- package/dist/constants.js +40 -6
- 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 +109 -20
- package/dist/hooks.js +7 -1
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +6 -2
- package/dist/project-index.js +2 -0
- package/dist/sandbox.js +135 -11
- package/dist/sessions.js +37 -2
- package/dist/settings.js +111 -6
- package/dist/sub-agent.js +83 -8
- package/dist/task-board.js +105 -0
- package/dist/tools-advanced.js +304 -49
- package/dist/tools-browser.js +280 -19
- package/dist/tools-extra.js +7 -0
- 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 +6 -2
- 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,14 @@ export class Agent extends EventEmitter {
|
|
|
497
648
|
let turnCount = 0;
|
|
498
649
|
let retryAttempt = 0;
|
|
499
650
|
let emptyTurnCount = 0;
|
|
651
|
+
let disableTools = false;
|
|
500
652
|
while (turnCount < this.config.maxSteps) {
|
|
501
653
|
if (this._aborted) {
|
|
502
654
|
reqLog.info('Agent aborted by user');
|
|
655
|
+
this.deregisterFromRegistry('aborted');
|
|
503
656
|
return '[interrupted]';
|
|
504
657
|
}
|
|
658
|
+
this.checkInbox();
|
|
505
659
|
turnCount++;
|
|
506
660
|
let fullText = '';
|
|
507
661
|
const toolCalls = new Map();
|
|
@@ -525,29 +679,44 @@ export class Agent extends EventEmitter {
|
|
|
525
679
|
toolsForRequest = this.toolSelector.select(messageContentToString(lastUserMsg.content), recentTools);
|
|
526
680
|
}
|
|
527
681
|
}
|
|
528
|
-
const apiTools = toolsForRequest.length > 0 ? transformToolsForApi(toolsForRequest) : undefined;
|
|
682
|
+
const apiTools = !disableTools && toolsForRequest.length > 0 ? transformToolsForApi(toolsForRequest) : undefined;
|
|
529
683
|
const estimatedTokens = estimateMessageTokens(this.messages);
|
|
530
684
|
this.emit('cost:estimate', { estimatedTokens, model: currentModel });
|
|
531
685
|
this._abortController = new AbortController();
|
|
686
|
+
let effectiveInstructions = this.config.instructions;
|
|
687
|
+
if (this.retrievalConfig?.enabled && turnCount === 1) {
|
|
688
|
+
const lastUserMsg = [...this.messages].reverse().find((m) => m.role === 'user');
|
|
689
|
+
if (lastUserMsg?.content) {
|
|
690
|
+
const queryText = messageContentToString(lastUserMsg.content);
|
|
691
|
+
try {
|
|
692
|
+
const { buildQueryContext } = await import('./context-builder.js');
|
|
693
|
+
const contextSnippets = await buildQueryContext(queryText, process.cwd(), this.retrievalConfig);
|
|
694
|
+
if (contextSnippets) {
|
|
695
|
+
effectiveInstructions += '\n\n' + contextSnippets;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
reqLog.warn('Context retrieval failed', { error: err.message });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
532
703
|
const vision = modelSupportsVision(currentModel);
|
|
533
|
-
const apiMessages = messagesToApi(this.messages,
|
|
704
|
+
const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
|
|
534
705
|
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 {
|
|
706
|
+
const hasToolMessages = this.messages.some(m => m.role === 'tool' || (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0));
|
|
707
|
+
const useStreaming = !hasToolMessages;
|
|
708
|
+
if (useStreaming) {
|
|
709
|
+
const stream = await this.client.chatComplete({
|
|
710
|
+
model: currentModel,
|
|
711
|
+
messages: apiMessages,
|
|
712
|
+
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
713
|
+
temperature: 0.7,
|
|
714
|
+
stream: true,
|
|
715
|
+
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
716
|
+
}, this._abortController.signal);
|
|
717
|
+
this.emit('stream:start');
|
|
718
|
+
retryAttempt = 0;
|
|
719
|
+
this.recordModelSuccess(currentModel);
|
|
551
720
|
for await (const chunk of stream) {
|
|
552
721
|
if (this._aborted)
|
|
553
722
|
break;
|
|
@@ -590,8 +759,48 @@ export class Agent extends EventEmitter {
|
|
|
590
759
|
}
|
|
591
760
|
}
|
|
592
761
|
}
|
|
593
|
-
|
|
594
|
-
|
|
762
|
+
else {
|
|
763
|
+
this.emit('stream:start');
|
|
764
|
+
const result = await this.client.chatComplete({
|
|
765
|
+
model: currentModel,
|
|
766
|
+
messages: apiMessages,
|
|
767
|
+
tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
|
|
768
|
+
temperature: 0.7,
|
|
769
|
+
stream: false,
|
|
770
|
+
...(thinkingLevel && { thinking_level: thinkingLevel }),
|
|
771
|
+
}, this._abortController.signal);
|
|
772
|
+
retryAttempt = 0;
|
|
773
|
+
this.recordModelSuccess(currentModel);
|
|
774
|
+
const choice = result.choices?.[0];
|
|
775
|
+
if (choice?.message) {
|
|
776
|
+
if (choice.message.content) {
|
|
777
|
+
fullText = choice.message.content;
|
|
778
|
+
this.emit('stream:delta', fullText, fullText);
|
|
779
|
+
}
|
|
780
|
+
if (choice.message.tool_calls) {
|
|
781
|
+
for (const tc of choice.message.tool_calls) {
|
|
782
|
+
toolCalls.set(toolCalls.size, {
|
|
783
|
+
id: tc.id,
|
|
784
|
+
name: tc.function.name,
|
|
785
|
+
arguments: tc.function.arguments,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (result.usage) {
|
|
791
|
+
const inTok = result.usage.prompt_tokens ?? 0;
|
|
792
|
+
const outTok = result.usage.completion_tokens ?? 0;
|
|
793
|
+
const totalTok = result.usage.total_tokens ?? (inTok + outTok);
|
|
794
|
+
const cost = result.usage.cost ?? 0;
|
|
795
|
+
this.emit('usage', {
|
|
796
|
+
prompt_tokens: inTok,
|
|
797
|
+
completion_tokens: outTok,
|
|
798
|
+
total_tokens: totalTok,
|
|
799
|
+
cost,
|
|
800
|
+
model: currentModel,
|
|
801
|
+
});
|
|
802
|
+
this.replay.recordUsage(totalTok, cost);
|
|
803
|
+
}
|
|
595
804
|
}
|
|
596
805
|
for (const [, call] of toolCalls) {
|
|
597
806
|
this.emit('tool:call', call.name, call.arguments);
|
|
@@ -600,11 +809,23 @@ export class Agent extends EventEmitter {
|
|
|
600
809
|
this.emit('stream:end', fullText);
|
|
601
810
|
this.replay.record('stream_end', { text: fullText.slice(0, 200) });
|
|
602
811
|
const resolvedToolCalls = Array.from(toolCalls.values());
|
|
812
|
+
// Normalize hallucinated tool name prefixes (e.g. "default_api.write_file" → "write_file")
|
|
813
|
+
for (const tc of resolvedToolCalls) {
|
|
814
|
+
if (tc.name.includes('.')) {
|
|
815
|
+
const normalized = tc.name.split('.').pop();
|
|
816
|
+
reqLog.warn('Normalized tool name', { original: tc.name, normalized });
|
|
817
|
+
tc.name = normalized;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
603
820
|
const hasToolCalls = resolvedToolCalls.length > 0;
|
|
604
821
|
if (!fullText && !hasToolCalls) {
|
|
605
822
|
emptyTurnCount++;
|
|
606
823
|
reqLog.warn('Empty assistant turn (no text, no tool calls) — skipping', { emptyTurns: emptyTurnCount });
|
|
607
|
-
if (emptyTurnCount >=
|
|
824
|
+
if (emptyTurnCount >= 2 && !disableTools) {
|
|
825
|
+
disableTools = true;
|
|
826
|
+
reqLog.warn('Disabling tools due to repeated empty turns — model may not support function calling');
|
|
827
|
+
}
|
|
828
|
+
if (emptyTurnCount >= 5) {
|
|
608
829
|
reqLog.error('Too many empty turns, aborting');
|
|
609
830
|
return '[no response]';
|
|
610
831
|
}
|
|
@@ -662,50 +883,57 @@ export class Agent extends EventEmitter {
|
|
|
662
883
|
}
|
|
663
884
|
if (permission === 'ask') {
|
|
664
885
|
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;
|
|
886
|
+
if (this.config.permissionMode === 'auto') {
|
|
887
|
+
reqLog.info('Auto-approving tool (no confirmation handler in auto mode)', { tool: call.name });
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
|
|
891
|
+
reqLog.warn('Tool requires confirmation but no confirmToolCall provided', { tool: call.name });
|
|
892
|
+
this.emit('tool:blocked', call.name, 'No confirmation handler');
|
|
893
|
+
this.messages.push({
|
|
894
|
+
role: 'tool',
|
|
895
|
+
content: JSON.stringify(result),
|
|
896
|
+
tool_call_id: call.id,
|
|
897
|
+
name: call.name,
|
|
898
|
+
});
|
|
899
|
+
this.emit('tool:result', call.name, result);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
683
902
|
}
|
|
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,
|
|
903
|
+
else {
|
|
904
|
+
let args;
|
|
905
|
+
try {
|
|
906
|
+
args = JSON.parse(call.arguments);
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
args = call.arguments;
|
|
910
|
+
}
|
|
911
|
+
const CONFIRM_TIMEOUT_MS = 300_000;
|
|
912
|
+
const confirmed = await new Promise((resolve) => {
|
|
913
|
+
const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
|
|
914
|
+
this.config.confirmToolCall(call.name, args).then((v) => {
|
|
915
|
+
clearTimeout(timer);
|
|
916
|
+
resolve(v);
|
|
917
|
+
}).catch(() => {
|
|
918
|
+
clearTimeout(timer);
|
|
919
|
+
resolve(false);
|
|
920
|
+
});
|
|
706
921
|
});
|
|
707
|
-
this.
|
|
708
|
-
|
|
922
|
+
if (this._aborted)
|
|
923
|
+
break;
|
|
924
|
+
if (!confirmed) {
|
|
925
|
+
result = { denied: true, message: `User denied execution of ${call.name}` };
|
|
926
|
+
reqLog.info('Tool call denied by user', { tool: call.name });
|
|
927
|
+
this.emit('tool:blocked', call.name, 'User denied');
|
|
928
|
+
this.messages.push({
|
|
929
|
+
role: 'tool',
|
|
930
|
+
content: JSON.stringify(result),
|
|
931
|
+
tool_call_id: call.id,
|
|
932
|
+
name: call.name,
|
|
933
|
+
});
|
|
934
|
+
this.emit('tool:result', call.name, result);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
709
937
|
}
|
|
710
938
|
}
|
|
711
939
|
const execFn = tool?.function?.execute ?? tool?.execute;
|
|
@@ -722,7 +950,19 @@ export class Agent extends EventEmitter {
|
|
|
722
950
|
const toolTimeout = (typeof args?.timeout === 'number' && args.timeout > 0)
|
|
723
951
|
? Math.min(args.timeout, TOOL_DEFAULTS.maxCommandTimeoutMs)
|
|
724
952
|
: this.config.toolTimeoutMs;
|
|
725
|
-
result = await withTimeout(execFn(args, {
|
|
953
|
+
result = await withTimeout(execFn(args, {
|
|
954
|
+
context: {},
|
|
955
|
+
workspaceRoot: process.cwd(),
|
|
956
|
+
permissionMode: this.config.permissionMode,
|
|
957
|
+
agentId: this.agentId,
|
|
958
|
+
agentConfig: {
|
|
959
|
+
apiKey: this.config.apiKey,
|
|
960
|
+
baseUrl: this.client.baseURL,
|
|
961
|
+
models: this.config.models,
|
|
962
|
+
permissionMode: this.config.permissionMode,
|
|
963
|
+
confirmToolCall: this.config.confirmToolCall,
|
|
964
|
+
},
|
|
965
|
+
}), toolTimeout, call.name);
|
|
726
966
|
if (call.name === 'add_mcp_server' && result?.success) {
|
|
727
967
|
this.addTool({ type: 'mcp', serverLabel: args.label, serverUrl: args.url });
|
|
728
968
|
}
|
|
@@ -733,7 +973,11 @@ export class Agent extends EventEmitter {
|
|
|
733
973
|
}
|
|
734
974
|
}
|
|
735
975
|
else {
|
|
736
|
-
|
|
976
|
+
const availableNames = this.config.tools
|
|
977
|
+
.map((t) => t.function?.name || t.name)
|
|
978
|
+
.filter(Boolean)
|
|
979
|
+
.slice(0, 20);
|
|
980
|
+
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
981
|
}
|
|
738
982
|
let resultStr;
|
|
739
983
|
try {
|
|
@@ -759,6 +1003,23 @@ export class Agent extends EventEmitter {
|
|
|
759
1003
|
if (resultStr.length > MAX_TOOL_RESULT_CHARS) {
|
|
760
1004
|
resultStr = resultStr.slice(0, MAX_TOOL_RESULT_CHARS) + `\n...[truncated — ${(resultStr.length - MAX_TOOL_RESULT_CHARS).toLocaleString()} chars removed]`;
|
|
761
1005
|
}
|
|
1006
|
+
if (this.injectionDetectionEnabled) {
|
|
1007
|
+
const injectionCheck = detectPromptInjection(resultStr, call.name, this.injectionSensitivity, this.injectionCustomPatterns);
|
|
1008
|
+
if (injectionCheck.detected) {
|
|
1009
|
+
this.emit('injection:detected', injectionCheck);
|
|
1010
|
+
if (injectionCheck.confidence === 'high') {
|
|
1011
|
+
resultStr = formatInjectionWarning(injectionCheck) + '\n\n' + resultStr;
|
|
1012
|
+
}
|
|
1013
|
+
else if (injectionCheck.confidence === 'medium') {
|
|
1014
|
+
resultStr = `[Note: content may contain injection patterns (${injectionCheck.patterns.join(', ')})]\n\n` + resultStr;
|
|
1015
|
+
}
|
|
1016
|
+
reqLog.warn('Prompt injection detected in tool result', {
|
|
1017
|
+
tool: call.name,
|
|
1018
|
+
confidence: injectionCheck.confidence,
|
|
1019
|
+
patterns: injectionCheck.patterns,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
762
1023
|
this.messages.push({
|
|
763
1024
|
role: 'tool',
|
|
764
1025
|
content: resultStr,
|
|
@@ -824,6 +1085,23 @@ export class Agent extends EventEmitter {
|
|
|
824
1085
|
return '[interrupted]';
|
|
825
1086
|
continue;
|
|
826
1087
|
}
|
|
1088
|
+
if (this.groundingConfig?.enabled && this.groundingNudgeCount < this.groundingConfig.maxNudges) {
|
|
1089
|
+
const claims = extractCodeClaims(fullText);
|
|
1090
|
+
if (claims.length >= this.groundingConfig.minClaimsToCheck) {
|
|
1091
|
+
const groundingResult = checkGrounding(claims, this.messages, this.groundingConfig.ungroundedThreshold, this.groundingConfig.minClaimsToCheck);
|
|
1092
|
+
if (groundingResult.needsVerification) {
|
|
1093
|
+
this.groundingNudgeCount++;
|
|
1094
|
+
this.emit('grounding:unverified', groundingResult);
|
|
1095
|
+
this.messages.push({ role: 'system', content: formatGroundingNudge(groundingResult) });
|
|
1096
|
+
reqLog.info('Grounding check: nudging agent to verify claims', {
|
|
1097
|
+
total: claims.length,
|
|
1098
|
+
ungrounded: groundingResult.ungroundedClaims.length,
|
|
1099
|
+
ratio: groundingResult.groundingRatio,
|
|
1100
|
+
});
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
827
1105
|
return fullText;
|
|
828
1106
|
}
|
|
829
1107
|
catch (err) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export class ScalixClient {
|
|
2
|
+
baseURL;
|
|
3
|
+
apiKey;
|
|
4
|
+
constructor(baseURL, apiKey) {
|
|
5
|
+
this.baseURL = baseURL.replace(/\/+$/, '');
|
|
6
|
+
this.apiKey = apiKey;
|
|
7
|
+
}
|
|
8
|
+
async chatComplete(req, signal) {
|
|
9
|
+
const url = `${this.baseURL}/chat/completions`;
|
|
10
|
+
const body = JSON.stringify(req);
|
|
11
|
+
const response = await fetch(url, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
16
|
+
'User-Agent': 'Scalix-Coder/1.0',
|
|
17
|
+
},
|
|
18
|
+
body,
|
|
19
|
+
signal,
|
|
20
|
+
});
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const text = await response.text().catch(() => '');
|
|
23
|
+
throw new Error(`API request failed: ${response.status} ${response.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`);
|
|
24
|
+
}
|
|
25
|
+
if (req.stream) {
|
|
26
|
+
return this.parseSSEStream(response);
|
|
27
|
+
}
|
|
28
|
+
return await response.json();
|
|
29
|
+
}
|
|
30
|
+
async *parseSSEStream(response) {
|
|
31
|
+
const reader = response.body?.getReader();
|
|
32
|
+
if (!reader)
|
|
33
|
+
throw new Error('No response body for streaming');
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let buffer = '';
|
|
36
|
+
try {
|
|
37
|
+
while (true) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done)
|
|
40
|
+
break;
|
|
41
|
+
buffer += decoder.decode(value, { stream: true });
|
|
42
|
+
const lines = buffer.split('\n');
|
|
43
|
+
buffer = lines.pop() || '';
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed || trimmed.startsWith(':'))
|
|
47
|
+
continue;
|
|
48
|
+
if (trimmed.startsWith('data: ')) {
|
|
49
|
+
const data = trimmed.slice(6);
|
|
50
|
+
if (data === '[DONE]')
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
yield JSON.parse(data);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// skip malformed chunks
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (buffer.trim().startsWith('data: ')) {
|
|
62
|
+
const data = buffer.trim().slice(6);
|
|
63
|
+
if (data !== '[DONE]') {
|
|
64
|
+
try {
|
|
65
|
+
yield JSON.parse(data);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// skip
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
reader.releaseLock();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|