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/dist/agent.js CHANGED
@@ -1,4 +1,4 @@
1
- import OpenAI from 'openai';
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.client = new OpenAI({
146
- apiKey: config.apiKey,
147
- baseURL: config.baseUrl,
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.chat.completions.create({
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.chat.completions.create({
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
- return await this.loopStreamingTurns(initialModel, reqLog);
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, this.config.instructions, vision);
704
+ const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
534
705
  const thinkingLevel = this.config.thinkingMode !== 'off' ? this.config.thinkingMode : undefined;
535
- const stream = await this.client.chat.completions.create({
536
- model: currentModel,
537
- messages: apiMessages,
538
- tools: apiTools && apiTools.length > 0 ? apiTools : undefined,
539
- max_tokens: modelSpec.maxOutputTokens,
540
- temperature: 0.7,
541
- stream: true,
542
- stream_options: { include_usage: true },
543
- ...(thinkingLevel && { thinking_level: thinkingLevel }),
544
- }, {
545
- signal: this._abortController.signal,
546
- });
547
- this.emit('stream:start');
548
- retryAttempt = 0;
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
- finally {
594
- stream.controller?.abort();
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 >= 3) {
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
- result = { denied: true, message: `Tool ${call.name} requires confirmation but no confirmation handler is available` };
666
- reqLog.warn('Tool requires confirmation but no confirmToolCall provided', { tool: call.name });
667
- this.emit('tool:blocked', call.name, 'No confirmation handler');
668
- this.messages.push({
669
- role: 'tool',
670
- content: JSON.stringify(result),
671
- tool_call_id: call.id,
672
- name: call.name,
673
- });
674
- this.emit('tool:result', call.name, result);
675
- continue;
676
- }
677
- let args;
678
- try {
679
- args = JSON.parse(call.arguments);
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
- const CONFIRM_TIMEOUT_MS = 300_000;
685
- const confirmed = await new Promise((resolve) => {
686
- const timer = setTimeout(() => resolve(false), CONFIRM_TIMEOUT_MS);
687
- this.config.confirmToolCall(call.name, args).then((v) => {
688
- clearTimeout(timer);
689
- resolve(v);
690
- }).catch(() => {
691
- clearTimeout(timer);
692
- resolve(false);
693
- });
694
- });
695
- if (this._aborted)
696
- break;
697
- if (!confirmed) {
698
- result = { denied: true, message: `User denied execution of ${call.name}` };
699
- reqLog.info('Tool call denied by user', { tool: call.name });
700
- this.emit('tool:blocked', call.name, 'User denied');
701
- this.messages.push({
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.emit('tool:result', call.name, result);
708
- continue;
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, { context: {}, workspaceRoot: process.cwd(), permissionMode: this.config.permissionMode }), toolTimeout, call.name);
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
- result = { error: `Tool ${call.name} not found or not locally executable.` };
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
+ }