@juspay/neurolink 10.5.2 → 10.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/agent/agent.js +39 -8
  3. package/dist/agent/agentNetwork.js +62 -20
  4. package/dist/agent/agentToolRegistrar.d.ts +48 -0
  5. package/dist/agent/agentToolRegistrar.js +336 -0
  6. package/dist/agent/index.d.ts +3 -0
  7. package/dist/agent/index.js +4 -0
  8. package/dist/agent/isolatedAgentRunner.d.ts +73 -0
  9. package/dist/agent/isolatedAgentRunner.js +946 -0
  10. package/dist/agent/structuredRecovery.d.ts +30 -0
  11. package/dist/agent/structuredRecovery.js +104 -0
  12. package/dist/browser/neurolink.min.js +414 -398
  13. package/dist/cli/loop/optionsSchema.d.ts +1 -1
  14. package/dist/core/baseProvider.d.ts +8 -0
  15. package/dist/core/baseProvider.js +40 -19
  16. package/dist/core/modules/GenerationHandler.d.ts +2 -6
  17. package/dist/core/modules/GenerationHandler.js +10 -1
  18. package/dist/core/toolExecutionRecorder.d.ts +76 -0
  19. package/dist/core/toolExecutionRecorder.js +261 -0
  20. package/dist/evaluation/contextBuilder.js +6 -6
  21. package/dist/lib/agent/agent.js +39 -8
  22. package/dist/lib/agent/agentNetwork.js +62 -20
  23. package/dist/lib/agent/agentToolRegistrar.d.ts +48 -0
  24. package/dist/lib/agent/agentToolRegistrar.js +337 -0
  25. package/dist/lib/agent/index.d.ts +3 -0
  26. package/dist/lib/agent/index.js +4 -0
  27. package/dist/lib/agent/isolatedAgentRunner.d.ts +73 -0
  28. package/dist/lib/agent/isolatedAgentRunner.js +947 -0
  29. package/dist/lib/agent/structuredRecovery.d.ts +30 -0
  30. package/dist/lib/agent/structuredRecovery.js +105 -0
  31. package/dist/lib/core/baseProvider.d.ts +8 -0
  32. package/dist/lib/core/baseProvider.js +40 -19
  33. package/dist/lib/core/modules/GenerationHandler.d.ts +2 -6
  34. package/dist/lib/core/modules/GenerationHandler.js +10 -1
  35. package/dist/lib/core/toolExecutionRecorder.d.ts +76 -0
  36. package/dist/lib/core/toolExecutionRecorder.js +262 -0
  37. package/dist/lib/evaluation/contextBuilder.js +6 -6
  38. package/dist/lib/models/modelRegistry.d.ts +23 -0
  39. package/dist/lib/models/modelRegistry.js +62 -1
  40. package/dist/lib/models/modelResolver.js +1 -0
  41. package/dist/lib/neurolink.d.ts +80 -1
  42. package/dist/lib/neurolink.js +199 -37
  43. package/dist/lib/providers/amazonBedrock.js +15 -2
  44. package/dist/lib/providers/anthropic.js +23 -11
  45. package/dist/lib/providers/googleAiStudio.js +8 -1
  46. package/dist/lib/providers/googleNativeGemini3.d.ts +2 -0
  47. package/dist/lib/providers/googleNativeGemini3.js +8 -1
  48. package/dist/lib/providers/googleVertex.js +56 -16
  49. package/dist/lib/providers/openaiChatCompletionsClient.js +18 -5
  50. package/dist/lib/tasks/autoresearchTaskExecutor.js +7 -6
  51. package/dist/lib/tasks/taskExecutor.js +2 -5
  52. package/dist/lib/types/agentNetwork.d.ts +23 -8
  53. package/dist/lib/types/generate.d.ts +62 -5
  54. package/dist/lib/types/index.d.ts +1 -0
  55. package/dist/lib/types/index.js +1 -0
  56. package/dist/lib/types/isolatedAgent.d.ts +365 -0
  57. package/dist/lib/types/isolatedAgent.js +12 -0
  58. package/dist/lib/types/model.d.ts +8 -0
  59. package/dist/lib/types/stream.d.ts +3 -1
  60. package/dist/lib/types/task.d.ts +3 -3
  61. package/dist/lib/utils/logger.d.ts +12 -3
  62. package/dist/lib/utils/logger.js +11 -3
  63. package/dist/lib/utils/transformationUtils.d.ts +14 -0
  64. package/dist/lib/utils/transformationUtils.js +33 -10
  65. package/dist/lib/voice/livekit/realtimeVoiceAgent.js +6 -3
  66. package/dist/models/modelRegistry.d.ts +23 -0
  67. package/dist/models/modelRegistry.js +62 -1
  68. package/dist/models/modelResolver.js +1 -0
  69. package/dist/neurolink.d.ts +80 -1
  70. package/dist/neurolink.js +199 -37
  71. package/dist/providers/amazonBedrock.js +15 -2
  72. package/dist/providers/anthropic.js +23 -11
  73. package/dist/providers/googleAiStudio.js +8 -1
  74. package/dist/providers/googleNativeGemini3.d.ts +2 -0
  75. package/dist/providers/googleNativeGemini3.js +8 -1
  76. package/dist/providers/googleVertex.js +56 -16
  77. package/dist/providers/openaiChatCompletionsClient.js +18 -5
  78. package/dist/tasks/autoresearchTaskExecutor.js +7 -6
  79. package/dist/tasks/taskExecutor.js +2 -5
  80. package/dist/types/agentNetwork.d.ts +23 -8
  81. package/dist/types/generate.d.ts +62 -5
  82. package/dist/types/index.d.ts +1 -0
  83. package/dist/types/index.js +1 -0
  84. package/dist/types/isolatedAgent.d.ts +365 -0
  85. package/dist/types/isolatedAgent.js +11 -0
  86. package/dist/types/model.d.ts +8 -0
  87. package/dist/types/stream.d.ts +3 -1
  88. package/dist/types/task.d.ts +3 -3
  89. package/dist/utils/logger.d.ts +12 -3
  90. package/dist/utils/logger.js +11 -3
  91. package/dist/utils/transformationUtils.d.ts +14 -0
  92. package/dist/utils/transformationUtils.js +33 -10
  93. package/dist/voice/livekit/realtimeVoiceAgent.js +6 -3
  94. package/package.json +3 -2
@@ -4,4 +4,4 @@ import type { OptionSchema, TextGenerationOptions } from "../../lib/types/index.
4
4
  * This object provides metadata for validation and help text in the CLI loop.
5
5
  * It is derived from the main TextGenerationOptions interface to ensure consistency.
6
6
  */
7
- export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation">, OptionSchema>;
7
+ export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation" | "toolExecutionCapture" | "toolExecutionRecorder">, OptionSchema>;
@@ -132,6 +132,14 @@ export declare abstract class BaseProvider implements AIProvider {
132
132
  * All providers should call this instead of getAllTools() directly.
133
133
  */
134
134
  protected getToolsForStream(options: StreamOptions | TextGenerationOptions): Promise<Record<string, Tool>>;
135
+ /**
136
+ * Create (or reuse) the per-call ToolExecutionRecorder and wrap the final
137
+ * tool record with it. The recorder rides on the options object so provider
138
+ * loops and result assembly observe the same capture state; wrapping is
139
+ * idempotent, so paths that re-enter (stream→generate fallback) never
140
+ * double-record.
141
+ */
142
+ protected wrapToolsForExecutionCapture(tools: Record<string, Tool>, options: StreamOptions | TextGenerationOptions): Record<string, Tool>;
135
143
  /**
136
144
  * Build messages array for generation - delegated to MessageBuilder
137
145
  */
@@ -12,6 +12,7 @@ import { withTimeoutFn } from "../utils/async/withTimeout.js";
12
12
  import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
13
13
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
14
14
  import { getKeyCount, getKeysAsString } from "../utils/transformationUtils.js";
15
+ import { ToolExecutionRecorder, resolveToolExecutionRecords, } from "./toolExecutionRecorder.js";
15
16
  import { TTSProcessor } from "../utils/ttsProcessor.js";
16
17
  import { executeVideoAnalysis, hasVideoFrames, } from "../utils/videoAnalysisProcessor.js";
17
18
  import { dedupeTools } from "./toolDedup.js";
@@ -759,6 +760,11 @@ export class BaseProvider {
759
760
  : {};
760
761
  // Apply per-call tool filtering (whitelist/blacklist)
761
762
  tools = this.applyToolFiltering(tools, options);
763
+ // Per-call execution capture: wrap every executable tool so real
764
+ // params/results/timing surface on result.toolExecutions. Must run
765
+ // BEFORE discovery — search_tools hydration mutates the discovery
766
+ // record in place, so tools hydrated mid-turn stay wrapped.
767
+ tools = this.wrapToolsForExecutionCapture(tools, options);
762
768
  // On-demand discovery: defer external MCP schemas behind search_tools
763
769
  tools = await this.applyToolDiscovery(tools, options);
764
770
  logger.debug(`Final tools prepared for AI`, {
@@ -792,6 +798,10 @@ export class BaseProvider {
792
798
  let merged = { ...baseTools, ...externalTools };
793
799
  // Apply per-call tool filtering (whitelist/blacklist)
794
800
  merged = this.applyToolFiltering(merged, options);
801
+ // Per-call execution capture (native loops obtain their tools here, so
802
+ // this single wrap covers the Gemini/Anthropic native paths too). Must
803
+ // run BEFORE discovery so tools hydrated mid-turn stay wrapped.
804
+ merged = this.wrapToolsForExecutionCapture(merged, options);
795
805
  // On-demand discovery: defer external MCP schemas behind search_tools
796
806
  merged = await this.applyToolDiscovery(merged, options);
797
807
  logger.debug(`Tools prepared for streaming`, {
@@ -802,6 +812,24 @@ export class BaseProvider {
802
812
  });
803
813
  return merged;
804
814
  }
815
+ /**
816
+ * Create (or reuse) the per-call ToolExecutionRecorder and wrap the final
817
+ * tool record with it. The recorder rides on the options object so provider
818
+ * loops and result assembly observe the same capture state; wrapping is
819
+ * idempotent, so paths that re-enter (stream→generate fallback) never
820
+ * double-record.
821
+ */
822
+ wrapToolsForExecutionCapture(tools, options) {
823
+ if (Object.keys(tools).length === 0) {
824
+ return tools;
825
+ }
826
+ let recorder = ToolExecutionRecorder.from(options);
827
+ if (!recorder) {
828
+ recorder = new ToolExecutionRecorder(options.toolExecutionCapture);
829
+ recorder.attachTo(options);
830
+ }
831
+ return recorder.wrapTools(tools);
832
+ }
805
833
  /**
806
834
  * Build messages array for generation - delegated to MessageBuilder
807
835
  */
@@ -1091,7 +1119,11 @@ export class BaseProvider {
1091
1119
  const responseTime = Date.now() - startTime;
1092
1120
  await this.recordPerformanceMetrics(generateResult.usage, responseTime);
1093
1121
  const { toolsUsed, toolExecutions } = this.extractToolInformation(generateResult);
1094
- let enhancedResult = this.formatEnhancedResult(generateResult, tools, toolsUsed, toolExecutions, options);
1122
+ // Prefer the per-call recorder's real records (params/result/timing per
1123
+ // execution); fall back to a conversion of the step-extraction entries
1124
+ // for tools the recorder could not wrap (provider-executed tools).
1125
+ const toolExecutionRecords = resolveToolExecutionRecords(options, toolExecutions);
1126
+ let enhancedResult = this.formatEnhancedResult(generateResult, tools, toolsUsed, toolExecutionRecords, options);
1095
1127
  enhancedResult = await this.synthesizeAIResponseIfNeeded(enhancedResult, options);
1096
1128
  const finalResult = await this.enhanceResult(enhancedResult, options, startTime);
1097
1129
  return finalResult;
@@ -1160,24 +1192,13 @@ export class BaseProvider {
1160
1192
  },
1161
1193
  responseTime: 0, // BaseProvider doesn't track response time directly
1162
1194
  toolsUsed: result.toolsUsed || [],
1163
- // Map toolExecutions from EnhancedGenerateResult shape to TextGenerationResult shape
1164
- // Preserve original timing/status fields when present, fall back to safe defaults
1165
- toolExecutions: result.toolExecutions?.map((te) => {
1166
- const t = te;
1167
- return {
1168
- // Spread original fields first so normalized fields take precedence
1169
- ...te,
1170
- toolName: te.name,
1171
- executionTime: typeof t.executionTime === "number"
1172
- ? t.executionTime
1173
- : typeof t.duration === "number"
1174
- ? t.duration
1175
- : 0,
1176
- success: typeof t.success === "boolean"
1177
- ? t.success
1178
- : t.status === undefined || t.status === "success",
1179
- };
1180
- }),
1195
+ // Map ToolExecutionRecord entries to the legacy TextGenerationResult
1196
+ // shape: real timing and error status now come from the records.
1197
+ toolExecutions: result.toolExecutions?.map((te) => ({
1198
+ toolName: te.toolName,
1199
+ executionTime: te.durationMs,
1200
+ success: !te.isError,
1201
+ })),
1181
1202
  enhancedWithTools: !!(result.toolsUsed && result.toolsUsed.length > 0),
1182
1203
  analytics: result.analytics,
1183
1204
  evaluation: result.evaluation,
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * @module core/modules/GenerationHandler
14
14
  */
15
- import type { AIProviderName, EnhancedGenerateResult, NeuroLinkEvents, StandardRecord, TextGenerationOptions, TypedEventEmitter } from "../../types/index.js";
15
+ import type { AIProviderName, EnhancedGenerateResult, NeuroLinkEvents, StandardRecord, TextGenerationOptions, ToolExecutionRecord, TypedEventEmitter } from "../../types/index.js";
16
16
  import type { LanguageModel, ModelMessage, Tool } from "../../types/index.js";
17
17
  import { generateText } from "../../utils/generation.js";
18
18
  /**
@@ -87,11 +87,7 @@ export declare class GenerationHandler {
87
87
  /**
88
88
  * Format the enhanced result
89
89
  */
90
- formatEnhancedResult(generateResult: Awaited<ReturnType<typeof generateText>>, tools: Record<string, Tool>, toolsUsed: string[], toolExecutions: Array<{
91
- name: string;
92
- input: StandardRecord;
93
- output: unknown;
94
- }>, options: TextGenerationOptions): EnhancedGenerateResult;
90
+ formatEnhancedResult(generateResult: Awaited<ReturnType<typeof generateText>>, tools: Record<string, Tool>, toolsUsed: string[], toolExecutions: ToolExecutionRecord[], options: TextGenerationOptions): EnhancedGenerateResult;
95
91
  /**
96
92
  * Analyze AI response structure and log detailed debugging information
97
93
  */
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
16
16
  import { getModelId } from "../../providers/providerTypeUtils.js";
17
+ import { resolveSamplingParams } from "../../models/modelRegistry.js";
17
18
  import { tracers } from "../../telemetry/tracers.js";
18
19
  import { logger } from "../../utils/logger.js";
19
20
  import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
@@ -256,6 +257,14 @@ export class GenerationHandler {
256
257
  return cachedOverhead.tokens;
257
258
  },
258
259
  });
260
+ // Registry-driven strip: models that reject sampling params (Sonnet 5 /
261
+ // Opus 4.7+ / Fable 5 families — e.g. Claude on Bedrock or behind any
262
+ // AI-SDK provider) must not receive temperature. Applies uniformly to
263
+ // every provider on this loop path; the reactive
264
+ // isTemperatureDeprecatedError retry below remains the safety net.
265
+ const samplingParams = resolveSamplingParams(this.providerName, getModelId(model, this.modelName || ""), options.temperature !== undefined
266
+ ? { temperature: options.temperature }
267
+ : {}, "aiSdk.generateText");
259
268
  const result = await generateText({
260
269
  model,
261
270
  ...(system && { system }),
@@ -308,7 +317,7 @@ export class GenerationHandler {
308
317
  }
309
318
  return prepared;
310
319
  }),
311
- temperature: options.temperature,
320
+ temperature: samplingParams.temperature,
312
321
  maxOutputTokens: options.maxTokens,
313
322
  maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
314
323
  abortSignal: options.abortSignal,
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ToolExecutionRecorder — real per-call tool execution capture.
3
+ *
4
+ * One recorder is created per generate()/stream() call and attached to the
5
+ * request options (surviving option spreads). Tools are wrapped exactly once
6
+ * at the central tool-assembly points (BaseProvider.prepareGenerationContext /
7
+ * getToolsForStream), so every loop — the AI-SDK loop and the native
8
+ * Gemini/Anthropic loops alike — records through the same wrapper with real
9
+ * params, timing, and error status. Memory is bounded: serialized results are
10
+ * capped at `maxResultChars` and the record list at `maxRecords`.
11
+ */
12
+ import type { Tool, ToolExecutionCaptureOptions, ToolExecutionRecord } from "../types/index.js";
13
+ /** Default cap on serialized result characters kept per record (~8KB). */
14
+ export declare const DEFAULT_TOOL_RESULT_CAPTURE_CHARS = 8192;
15
+ /** Default cap on records kept per turn. */
16
+ export declare const DEFAULT_TOOL_EXECUTION_MAX_RECORDS = 500;
17
+ /**
18
+ * Serialize a tool result to bounded text. JSON when serializable, else
19
+ * String(); truncation is explicit so consumers know the text is partial.
20
+ */
21
+ export declare function serializeToolResult(result: unknown, maxChars: number): string;
22
+ /**
23
+ * Error-shaped tool results (MCP `isError`, `{error}` payloads) count as
24
+ * failures even though the loop received them as normal returns.
25
+ */
26
+ export declare function isErrorShapedToolResult(result: unknown): boolean;
27
+ /**
28
+ * Per-call recorder for tool executions. See module doc.
29
+ */
30
+ export declare class ToolExecutionRecorder {
31
+ #private;
32
+ constructor(capture?: ToolExecutionCaptureOptions);
33
+ /** Record one completed (or failed) execution. Never throws. */
34
+ record(entry: {
35
+ toolName: string;
36
+ params: unknown;
37
+ result?: unknown;
38
+ error?: unknown;
39
+ startedAt: number;
40
+ durationMs: number;
41
+ }): void;
42
+ /** All records captured so far (oldest first, bounded by maxRecords). */
43
+ getRecords(): ToolExecutionRecord[];
44
+ /** True when at least one execution was recorded. */
45
+ hasRecords(): boolean;
46
+ /** Number of records evicted by the maxRecords cap. */
47
+ getDroppedCount(): number;
48
+ /**
49
+ * Wrap every executable tool so its invocations are recorded. Wrapping is
50
+ * idempotent — already-wrapped executes are left untouched, so a generate
51
+ * that falls back to another path never double-records.
52
+ */
53
+ wrapTools(tools: Record<string, Tool>): Record<string, Tool>;
54
+ /**
55
+ * Attach this recorder to a request options object. The property is
56
+ * enumerable on purpose: downstream stages spread options (`{...options}`)
57
+ * and the recorder must survive into the provider loops.
58
+ */
59
+ attachTo(options: Record<string, unknown>): void;
60
+ /** Retrieve the recorder a request is carrying, if any. */
61
+ static from(options: unknown): ToolExecutionRecorder | undefined;
62
+ }
63
+ /**
64
+ * Convert legacy loop-local `{name, input, output}` entries (plus optional
65
+ * timing/error fields) into `ToolExecutionRecord`s. Used as the fallback when
66
+ * a path produced executions the recorder did not see (e.g. results captured
67
+ * before the recorder existed, or breaker/not-found entries that never
68
+ * reached a real execute()).
69
+ */
70
+ export declare function toToolExecutionRecords(legacyExecutions: unknown[] | undefined, capture?: ToolExecutionCaptureOptions): ToolExecutionRecord[];
71
+ /**
72
+ * Resolve the toolExecutions for a result: recorder records when the request
73
+ * carried a recorder that saw executions, else a conversion of the loop's
74
+ * legacy accumulator entries.
75
+ */
76
+ export declare function resolveToolExecutionRecords(options: unknown, legacyExecutions?: unknown[]): ToolExecutionRecord[];
@@ -0,0 +1,261 @@
1
+ /**
2
+ * ToolExecutionRecorder — real per-call tool execution capture.
3
+ *
4
+ * One recorder is created per generate()/stream() call and attached to the
5
+ * request options (surviving option spreads). Tools are wrapped exactly once
6
+ * at the central tool-assembly points (BaseProvider.prepareGenerationContext /
7
+ * getToolsForStream), so every loop — the AI-SDK loop and the native
8
+ * Gemini/Anthropic loops alike — records through the same wrapper with real
9
+ * params, timing, and error status. Memory is bounded: serialized results are
10
+ * capped at `maxResultChars` and the record list at `maxRecords`.
11
+ */
12
+ import { logger } from "../utils/logger.js";
13
+ /** Default cap on serialized result characters kept per record (~8KB). */
14
+ export const DEFAULT_TOOL_RESULT_CAPTURE_CHARS = 8192;
15
+ /** Default cap on records kept per turn. */
16
+ export const DEFAULT_TOOL_EXECUTION_MAX_RECORDS = 500;
17
+ /** Marker set on execute functions the recorder has already wrapped. */
18
+ const RECORDER_WRAPPED = Symbol.for("neurolink.toolExecutionRecorder.wrapped");
19
+ /** Internal options key the per-call recorder rides on (enumerable so it survives option spreads). */
20
+ const RECORDER_OPTIONS_KEY = "toolExecutionRecorder";
21
+ /**
22
+ * Serialize a tool result to bounded text. JSON when serializable, else
23
+ * String(); truncation is explicit so consumers know the text is partial.
24
+ */
25
+ export function serializeToolResult(result, maxChars) {
26
+ let text;
27
+ if (typeof result === "string") {
28
+ text = result;
29
+ }
30
+ else {
31
+ try {
32
+ text = JSON.stringify(result) ?? String(result);
33
+ }
34
+ catch {
35
+ text = String(result);
36
+ }
37
+ }
38
+ if (text.length > maxChars) {
39
+ const dropped = text.length - maxChars;
40
+ text = `${text.slice(0, maxChars)}…[truncated ${dropped} chars]`;
41
+ }
42
+ return text;
43
+ }
44
+ /**
45
+ * Error-shaped tool results (MCP `isError`, `{error}` payloads) count as
46
+ * failures even though the loop received them as normal returns.
47
+ */
48
+ export function isErrorShapedToolResult(result) {
49
+ if (!result || typeof result !== "object") {
50
+ return false;
51
+ }
52
+ const record = result;
53
+ if (record.isError === true) {
54
+ return true;
55
+ }
56
+ if (record.error !== undefined && record.error !== null) {
57
+ return true;
58
+ }
59
+ if (typeof record.status === "string") {
60
+ return ["error", "failed", "failure", "fail"].includes(record.status.toLowerCase().trim());
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Per-call recorder for tool executions. See module doc.
66
+ */
67
+ export class ToolExecutionRecorder {
68
+ // ES #private fields on purpose: the recorder rides on request options as
69
+ // an ENUMERABLE property (spread survival), so TS-only `private` fields
70
+ // would still be dragged into any JSON.stringify(options) — up to
71
+ // maxRecords × maxResultChars of tool results in a debug dump. #fields are
72
+ // invisible to serialization: the recorder stringifies as `{}`.
73
+ #maxResultChars;
74
+ #maxRecords;
75
+ #onRecord;
76
+ #records = [];
77
+ #droppedRecords = 0;
78
+ constructor(capture) {
79
+ this.#maxResultChars =
80
+ capture?.maxResultChars ?? DEFAULT_TOOL_RESULT_CAPTURE_CHARS;
81
+ this.#maxRecords =
82
+ capture?.maxRecords ?? DEFAULT_TOOL_EXECUTION_MAX_RECORDS;
83
+ this.#onRecord = capture?.onRecord;
84
+ }
85
+ /** Record one completed (or failed) execution. Never throws. */
86
+ record(entry) {
87
+ try {
88
+ const failed = entry.error !== undefined;
89
+ const record = {
90
+ toolName: entry.toolName,
91
+ params: entry.params,
92
+ resultText: serializeToolResult(failed
93
+ ? entry.error instanceof Error
94
+ ? entry.error.message
95
+ : entry.error
96
+ : entry.result, this.#maxResultChars),
97
+ isError: failed || isErrorShapedToolResult(entry.result),
98
+ startedAt: entry.startedAt,
99
+ durationMs: entry.durationMs,
100
+ };
101
+ this.#records.push(record);
102
+ if (this.#records.length > this.#maxRecords) {
103
+ this.#records.splice(0, this.#records.length - this.#maxRecords);
104
+ this.#droppedRecords++;
105
+ }
106
+ if (this.#onRecord) {
107
+ try {
108
+ // Swallow async rejections too — the contract is that listener
109
+ // errors (sync OR async) never disrupt the turn.
110
+ void Promise.resolve(this.#onRecord(record)).catch(() => {
111
+ // Listener errors never break the run.
112
+ });
113
+ }
114
+ catch {
115
+ // Listener errors never break the run.
116
+ }
117
+ }
118
+ }
119
+ catch (recordError) {
120
+ logger.debug("[ToolExecutionRecorder] Failed to record execution", {
121
+ error: recordError instanceof Error
122
+ ? recordError.message
123
+ : String(recordError),
124
+ });
125
+ }
126
+ }
127
+ /** All records captured so far (oldest first, bounded by maxRecords). */
128
+ getRecords() {
129
+ return [...this.#records];
130
+ }
131
+ /** True when at least one execution was recorded. */
132
+ hasRecords() {
133
+ return this.#records.length > 0;
134
+ }
135
+ /** Number of records evicted by the maxRecords cap. */
136
+ getDroppedCount() {
137
+ return this.#droppedRecords;
138
+ }
139
+ /**
140
+ * Wrap every executable tool so its invocations are recorded. Wrapping is
141
+ * idempotent — already-wrapped executes are left untouched, so a generate
142
+ * that falls back to another path never double-records.
143
+ */
144
+ wrapTools(tools) {
145
+ // Null prototype: a tool named "__proto__" must stay an own entry
146
+ // (mirrors BaseProvider.sortToolRecord / toolDiscovery partitioning).
147
+ const wrapped = Object.create(null);
148
+ for (const [name, tool] of Object.entries(tools)) {
149
+ const execute = tool.execute;
150
+ if (typeof execute !== "function" ||
151
+ execute[RECORDER_WRAPPED]) {
152
+ wrapped[name] = tool;
153
+ continue;
154
+ }
155
+ const recorder = this;
156
+ const recordingExecute = async (params, execOptions) => {
157
+ const startedAt = Date.now();
158
+ try {
159
+ const result = await execute(params, execOptions);
160
+ recorder.record({
161
+ toolName: name,
162
+ params,
163
+ result,
164
+ startedAt,
165
+ durationMs: Date.now() - startedAt,
166
+ });
167
+ return result;
168
+ }
169
+ catch (error) {
170
+ recorder.record({
171
+ toolName: name,
172
+ params,
173
+ error,
174
+ startedAt,
175
+ durationMs: Date.now() - startedAt,
176
+ });
177
+ throw error;
178
+ }
179
+ };
180
+ recordingExecute[RECORDER_WRAPPED] = true;
181
+ wrapped[name] = { ...tool, execute: recordingExecute };
182
+ }
183
+ return wrapped;
184
+ }
185
+ /**
186
+ * Attach this recorder to a request options object. The property is
187
+ * enumerable on purpose: downstream stages spread options (`{...options}`)
188
+ * and the recorder must survive into the provider loops.
189
+ */
190
+ attachTo(options) {
191
+ options[RECORDER_OPTIONS_KEY] = this;
192
+ }
193
+ /** Retrieve the recorder a request is carrying, if any. */
194
+ static from(options) {
195
+ if (!options || typeof options !== "object") {
196
+ return undefined;
197
+ }
198
+ const candidate = options[RECORDER_OPTIONS_KEY];
199
+ return candidate instanceof ToolExecutionRecorder ? candidate : undefined;
200
+ }
201
+ }
202
+ /**
203
+ * Convert legacy loop-local `{name, input, output}` entries (plus optional
204
+ * timing/error fields) into `ToolExecutionRecord`s. Used as the fallback when
205
+ * a path produced executions the recorder did not see (e.g. results captured
206
+ * before the recorder existed, or breaker/not-found entries that never
207
+ * reached a real execute()).
208
+ */
209
+ export function toToolExecutionRecords(legacyExecutions, capture) {
210
+ if (!legacyExecutions || !Array.isArray(legacyExecutions)) {
211
+ return [];
212
+ }
213
+ const maxChars = capture?.maxResultChars ?? DEFAULT_TOOL_RESULT_CAPTURE_CHARS;
214
+ const maxRecords = capture?.maxRecords ?? DEFAULT_TOOL_EXECUTION_MAX_RECORDS;
215
+ return legacyExecutions.slice(-maxRecords).map((entry, index) => {
216
+ const record = (entry ?? {});
217
+ // A canonical record round-tripping through this converter keeps its
218
+ // already-serialized resultText verbatim instead of re-serializing an
219
+ // absent `output` into the string "undefined".
220
+ const output = record.output ??
221
+ record.result ??
222
+ record.response ??
223
+ (typeof record.resultText === "string" ? record.resultText : undefined);
224
+ const durationMs = typeof record.durationMs === "number"
225
+ ? record.durationMs
226
+ : typeof record.duration === "number"
227
+ ? record.duration
228
+ : typeof record.executionTime === "number"
229
+ ? record.executionTime
230
+ : 0;
231
+ return {
232
+ toolName: record.name ||
233
+ record.toolName ||
234
+ `tool_execution_${index}`,
235
+ params: record.input ?? record.params ?? record.args ?? {},
236
+ resultText: serializeToolResult(output, maxChars),
237
+ isError: record.isError === true ||
238
+ record.success === false ||
239
+ record.error !== undefined ||
240
+ isErrorShapedToolResult(output),
241
+ startedAt: typeof record.startedAt === "number" ? record.startedAt : 0,
242
+ durationMs,
243
+ };
244
+ });
245
+ }
246
+ /**
247
+ * Resolve the toolExecutions for a result: recorder records when the request
248
+ * carried a recorder that saw executions, else a conversion of the loop's
249
+ * legacy accumulator entries.
250
+ */
251
+ export function resolveToolExecutionRecords(options, legacyExecutions) {
252
+ const recorder = ToolExecutionRecorder.from(options);
253
+ if (recorder?.hasRecords()) {
254
+ return recorder.getRecords();
255
+ }
256
+ const capture = options && typeof options === "object"
257
+ ? options
258
+ .toolExecutionCapture
259
+ : undefined;
260
+ return toToolExecutionRecords(legacyExecutions, capture);
261
+ }
@@ -116,15 +116,15 @@ export class ContextBuilder {
116
116
  }
117
117
  return result.toolExecutions.map((exec) => {
118
118
  const toolResult = {
119
- success: true,
120
- data: exec.output,
119
+ success: !exec.isError,
120
+ data: exec.resultText,
121
121
  };
122
122
  return {
123
- toolName: exec.name,
124
- params: exec.input,
123
+ toolName: exec.toolName,
124
+ params: exec.params,
125
125
  result: toolResult,
126
- executionTime: 0,
127
- timestamp: Date.now(),
126
+ executionTime: exec.durationMs,
127
+ timestamp: exec.startedAt || Date.now(),
128
128
  };
129
129
  });
130
130
  }
@@ -108,6 +108,12 @@ export class Agent {
108
108
  timestamp: startTime,
109
109
  });
110
110
  try {
111
+ // No ghost runs: an already-aborted parent stops the agent before any
112
+ // model call is made. Typed so callers can branch on the ABORT
113
+ // category instead of parsing free text.
114
+ if (options?.abortSignal?.aborted) {
115
+ throw ErrorFactory.aborted(new Error("Agent execution aborted before start"));
116
+ }
111
117
  // Validate input if schema provided
112
118
  if (this.inputSchema && typeof input !== "string") {
113
119
  const validation = this.inputSchema.safeParse(input);
@@ -149,19 +155,14 @@ export class Agent {
149
155
  contentLength: result.content?.length || 0,
150
156
  toolsUsed: result.toolsUsed?.length || 0,
151
157
  });
152
- // Pass through toolExecutions, adding duration (not provided by generate())
153
- const toolExecutions = result.toolExecutions?.map((te) => ({
154
- name: te.name,
155
- input: te.input,
156
- output: te.output,
157
- duration: 0,
158
- }));
159
158
  const agentResult = {
160
159
  content: result.content || "",
161
160
  object: parsedOutput,
162
161
  usage: result.usage,
163
162
  toolsUsed: result.toolsUsed,
164
- toolExecutions,
163
+ // Real per-call records straight from the tool loop (N2)
164
+ toolExecutions: result.toolExecutions,
165
+ stopReason: result.stopReason,
165
166
  duration,
166
167
  status: "success",
167
168
  agentId: this.id,
@@ -219,6 +220,10 @@ export class Agent {
219
220
  traceId,
220
221
  };
221
222
  try {
223
+ // Mirror execute(): an already-aborted parent never starts the stream.
224
+ if (options?.abortSignal?.aborted) {
225
+ throw ErrorFactory.aborted(new Error("Agent stream aborted before start"));
226
+ }
222
227
  // Validate input if schema provided
223
228
  if (this.inputSchema && typeof input !== "string") {
224
229
  const validation = this.inputSchema.safeParse(input);
@@ -367,6 +372,20 @@ export class Agent {
367
372
  ...(this.tools && this.tools.length > 0 && { toolFilter: this.tools }),
368
373
  maxSteps: options?.maxSteps ?? this.maxSteps,
369
374
  requestId: traceId,
375
+ // Turn budget + cancellation, forwarded verbatim to generate() — the
376
+ // machinery (wrap-up nudge, stall watchdog, honest stopReason) lives
377
+ // in the tool loop; the agent layer just passes it through.
378
+ ...(options?.abortSignal && { abortSignal: options.abortSignal }),
379
+ ...(options?.timeout !== undefined && { timeout: options.timeout }),
380
+ ...(options?.turnTimeoutMs !== undefined && {
381
+ turnTimeoutMs: options.turnTimeoutMs,
382
+ }),
383
+ ...(options?.wrapupTimeLeadMs !== undefined && {
384
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
385
+ }),
386
+ ...(options?.stallTimeoutMs !== undefined && {
387
+ stallTimeoutMs: options.stallTimeoutMs,
388
+ }),
370
389
  context: {
371
390
  agentId: this.id,
372
391
  agentName: this.name,
@@ -390,6 +409,18 @@ export class Agent {
390
409
  // toolFilter delegates to BaseProvider.applyToolFiltering() natively
391
410
  ...(this.tools && this.tools.length > 0 && { toolFilter: this.tools }),
392
411
  maxSteps: options?.maxSteps ?? this.maxSteps,
412
+ // Turn budget + cancellation, forwarded verbatim to stream()
413
+ ...(options?.abortSignal && { abortSignal: options.abortSignal }),
414
+ ...(options?.timeout !== undefined && { timeout: options.timeout }),
415
+ ...(options?.turnTimeoutMs !== undefined && {
416
+ turnTimeoutMs: options.turnTimeoutMs,
417
+ }),
418
+ ...(options?.wrapupTimeLeadMs !== undefined && {
419
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
420
+ }),
421
+ ...(options?.stallTimeoutMs !== undefined && {
422
+ stallTimeoutMs: options.stallTimeoutMs,
423
+ }),
393
424
  context: {
394
425
  agentId: this.id,
395
426
  agentName: this.name,