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/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,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, this.config.instructions, vision);
705
+ const apiMessages = messagesToApi(this.messages, effectiveInstructions, vision);
534
706
  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 {
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
- finally {
594
- stream.controller?.abort();
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 >= 3) {
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
- 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;
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
- 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,
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.emit('tool:result', call.name, result);
708
- continue;
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, { context: {}, workspaceRoot: process.cwd(), permissionMode: this.config.permissionMode }), toolTimeout, call.name);
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
- result = { error: `Tool ${call.name} not found or not locally executable.` };
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,