@jaypie/llm 1.2.39 → 1.2.41

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/esm/index.js CHANGED
@@ -5,6 +5,8 @@ import { placeholders, JAYPIE, resolveValue, sleep } from '@jaypie/kit';
5
5
  import RandomLib from 'random';
6
6
  import { RateLimitError, APIConnectionError, APIConnectionTimeoutError, InternalServerError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, NotFoundError, PermissionDeniedError, UnprocessableEntityError, OpenAI } from 'openai';
7
7
  import { zodResponseFormat } from 'openai/helpers/zod';
8
+ import { createRequire } from 'module';
9
+ import { pathToFileURL } from 'url';
8
10
  import { PDFDocument } from 'pdf-lib';
9
11
  import { readFile } from 'fs/promises';
10
12
  import { resolve } from 'path';
@@ -570,6 +572,154 @@ function extractReasoning(history) {
570
572
  return reasoningTexts;
571
573
  }
572
574
 
575
+ function naturalZodSchema(definition) {
576
+ if (Array.isArray(definition)) {
577
+ if (definition.length === 0) {
578
+ // Handle empty array - accept any[]
579
+ return z.array(z.any());
580
+ }
581
+ else if (definition.length === 1) {
582
+ // Handle array types
583
+ const itemType = definition[0];
584
+ switch (itemType) {
585
+ case String:
586
+ return z.array(z.string());
587
+ case Number:
588
+ return z.array(z.number());
589
+ case Boolean:
590
+ return z.array(z.boolean());
591
+ default:
592
+ if (typeof itemType === "object") {
593
+ // Handle array of objects
594
+ return z.array(naturalZodSchema(itemType));
595
+ }
596
+ // Handle enum arrays
597
+ return z.enum(definition);
598
+ }
599
+ }
600
+ else {
601
+ // Handle enum arrays
602
+ return z.enum(definition);
603
+ }
604
+ }
605
+ else if (definition && typeof definition === "object") {
606
+ if (Object.keys(definition).length === 0) {
607
+ // Handle empty object - accept any key-value pairs
608
+ return z.record(z.string(), z.any());
609
+ }
610
+ else {
611
+ // Handle object with properties
612
+ const schemaShape = {};
613
+ for (const [key, value] of Object.entries(definition)) {
614
+ schemaShape[key] = naturalZodSchema(value);
615
+ }
616
+ return z.object(schemaShape);
617
+ }
618
+ }
619
+ else {
620
+ switch (definition) {
621
+ case String:
622
+ return z.string();
623
+ case Number:
624
+ return z.number();
625
+ case Boolean:
626
+ return z.boolean();
627
+ case Object:
628
+ return z.record(z.string(), z.any());
629
+ case Array:
630
+ return z.array(z.any());
631
+ default:
632
+ throw new Error(`Unsupported type: ${definition}`);
633
+ }
634
+ }
635
+ }
636
+
637
+ //
638
+ //
639
+ // Helpers
640
+ //
641
+ function isPlainObject(value) {
642
+ return typeof value === "object" && value !== null && !Array.isArray(value);
643
+ }
644
+ /**
645
+ * Convert a `format` declaration (Zod schema, NaturalSchema, or a JSON Schema
646
+ * JsonObject) into a plain JSON Schema we can walk. Mirrors the conversion the
647
+ * provider adapters perform in `formatOutputSchema`, but without any
648
+ * provider-specific sanitization.
649
+ */
650
+ function formatToJsonSchema(format) {
651
+ if (format instanceof z.ZodType) {
652
+ return z.toJSONSchema(format);
653
+ }
654
+ if (isPlainObject(format) && format.type === "json_schema") {
655
+ const clone = structuredClone(format);
656
+ clone.type = "object";
657
+ return clone;
658
+ }
659
+ try {
660
+ return z.toJSONSchema(naturalZodSchema(format));
661
+ }
662
+ catch {
663
+ return undefined;
664
+ }
665
+ }
666
+ /**
667
+ * Walk a JSON Schema alongside a parsed value, filling any declared array field
668
+ * that is absent (`undefined`/`null`) with `[]`. Recurses into object
669
+ * properties and array items so nested declared arrays are also backfilled.
670
+ */
671
+ function fillFromSchema(schema, value) {
672
+ if (!isPlainObject(schema)) {
673
+ return value;
674
+ }
675
+ const type = schema.type;
676
+ const isArray = type === "array" || (type === undefined && "items" in schema);
677
+ if (isArray) {
678
+ if (value === undefined || value === null) {
679
+ return [];
680
+ }
681
+ const items = schema.items;
682
+ if (Array.isArray(value) && isPlainObject(items)) {
683
+ return value.map((entry) => fillFromSchema(items, entry));
684
+ }
685
+ return value;
686
+ }
687
+ const isObject = type === "object" || (type === undefined && "properties" in schema);
688
+ if (isObject) {
689
+ if (!isPlainObject(value)) {
690
+ return value;
691
+ }
692
+ const properties = schema.properties;
693
+ if (isPlainObject(properties)) {
694
+ for (const [key, propSchema] of Object.entries(properties)) {
695
+ value[key] = fillFromSchema(propSchema, value[key]);
696
+ }
697
+ }
698
+ return value;
699
+ }
700
+ return value;
701
+ }
702
+ //
703
+ //
704
+ // Main
705
+ //
706
+ /**
707
+ * Ensure every array field declared in `format` is present in `content` as an
708
+ * array. A declared `format` is a schema contract: an empty list should surface
709
+ * as `[]`, not be dropped from the response. Some providers/models omit empty
710
+ * array fields entirely, leaving consumers to read `.length` on `undefined`.
711
+ *
712
+ * Only mutates a (cloned) structured object; strings and non-objects pass
713
+ * through untouched.
714
+ */
715
+ function fillFormatArrays({ content, format, }) {
716
+ const schema = formatToJsonSchema(format);
717
+ if (!schema) {
718
+ return content;
719
+ }
720
+ return fillFromSchema(schema, structuredClone(content));
721
+ }
722
+
573
723
  /**
574
724
  * Converts a string to a standardized LlmInputMessage
575
725
  * @param input - String to format
@@ -697,68 +847,6 @@ function maxTurnsFromOptions(options) {
697
847
  return 1;
698
848
  }
699
849
 
700
- function naturalZodSchema(definition) {
701
- if (Array.isArray(definition)) {
702
- if (definition.length === 0) {
703
- // Handle empty array - accept any[]
704
- return z.array(z.any());
705
- }
706
- else if (definition.length === 1) {
707
- // Handle array types
708
- const itemType = definition[0];
709
- switch (itemType) {
710
- case String:
711
- return z.array(z.string());
712
- case Number:
713
- return z.array(z.number());
714
- case Boolean:
715
- return z.array(z.boolean());
716
- default:
717
- if (typeof itemType === "object") {
718
- // Handle array of objects
719
- return z.array(naturalZodSchema(itemType));
720
- }
721
- // Handle enum arrays
722
- return z.enum(definition);
723
- }
724
- }
725
- else {
726
- // Handle enum arrays
727
- return z.enum(definition);
728
- }
729
- }
730
- else if (definition && typeof definition === "object") {
731
- if (Object.keys(definition).length === 0) {
732
- // Handle empty object - accept any key-value pairs
733
- return z.record(z.string(), z.any());
734
- }
735
- else {
736
- // Handle object with properties
737
- const schemaShape = {};
738
- for (const [key, value] of Object.entries(definition)) {
739
- schemaShape[key] = naturalZodSchema(value);
740
- }
741
- return z.object(schemaShape);
742
- }
743
- }
744
- else {
745
- switch (definition) {
746
- case String:
747
- return z.string();
748
- case Number:
749
- return z.number();
750
- case Boolean:
751
- return z.boolean();
752
- case Object:
753
- return z.record(z.string(), z.any());
754
- case Array:
755
- return z.array(z.any());
756
- default:
757
- throw new Error(`Unsupported type: ${definition}`);
758
- }
759
- }
760
- }
761
-
762
850
  //
763
851
  // Constants
764
852
  //
@@ -4835,6 +4923,198 @@ class Toolkit {
4835
4923
  }
4836
4924
  }
4837
4925
 
4926
+ //
4927
+ //
4928
+ // Constants
4929
+ //
4930
+ const ENV = {
4931
+ DD_LLMOBS_ENABLED: "DD_LLMOBS_ENABLED",
4932
+ };
4933
+ const MODULE = {
4934
+ // Computed at runtime so bundlers (esbuild) do not attempt to include
4935
+ // dd-trace, which is provided by the Datadog Lambda layer at runtime.
4936
+ DD_TRACE: ["dd", "trace"].join("-"),
4937
+ };
4938
+ //
4939
+ //
4940
+ // Helpers
4941
+ //
4942
+ // CJS/ESM compatible require - handles bundling to CJS where import.meta.url
4943
+ // becomes undefined (mirrors packages/testkit/src/mock/original.ts).
4944
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
4945
+ // @ts-ignore - __filename exists in CJS context when ESM is bundled to CJS
4946
+ const requireModule = typeof __filename !== "undefined"
4947
+ ? createRequire(pathToFileURL(__filename).href)
4948
+ : createRequire(import.meta.url);
4949
+ let resolved = false;
4950
+ let cachedSdk$4 = null;
4951
+ /**
4952
+ * Lazily resolve the dd-trace `llmobs` SDK. Returns null (and never throws)
4953
+ * when dd-trace is absent or the SDK surface is unexpected. Cached after the
4954
+ * first attempt.
4955
+ */
4956
+ function resolveLlmObs() {
4957
+ if (resolved) {
4958
+ return cachedSdk$4;
4959
+ }
4960
+ resolved = true;
4961
+ try {
4962
+ const ddTrace = requireModule(MODULE.DD_TRACE);
4963
+ const tracer = ddTrace?.default ?? ddTrace;
4964
+ const llmobs = tracer?.llmobs;
4965
+ if (llmobs &&
4966
+ typeof llmobs.trace === "function" &&
4967
+ typeof llmobs.annotate === "function") {
4968
+ cachedSdk$4 = llmobs;
4969
+ }
4970
+ }
4971
+ catch {
4972
+ cachedSdk$4 = null;
4973
+ }
4974
+ return cachedSdk$4;
4975
+ }
4976
+ //
4977
+ //
4978
+ // Main
4979
+ //
4980
+ /**
4981
+ * True when LLM Observability emission is opted in via `DD_LLMOBS_ENABLED`.
4982
+ * Accepts any truthy value except "false"/"0".
4983
+ */
4984
+ function isLlmObsEnabled() {
4985
+ const value = process.env[ENV.DD_LLMOBS_ENABLED];
4986
+ if (!value) {
4987
+ return false;
4988
+ }
4989
+ const normalized = value.trim().toLowerCase();
4990
+ return normalized !== "" && normalized !== "false" && normalized !== "0";
4991
+ }
4992
+ /** Resolve the SDK only when enabled and available. */
4993
+ function activeSdk() {
4994
+ if (!isLlmObsEnabled()) {
4995
+ return null;
4996
+ }
4997
+ return resolveLlmObs();
4998
+ }
4999
+ /**
5000
+ * Run `fn` inside an LLM Observability span. When emission is disabled or
5001
+ * dd-trace is absent, `fn` is invoked directly with no overhead. The active
5002
+ * span is established for the duration of `fn`, so nested `withLlmObsSpan`
5003
+ * calls (and `annotateLlmObs`) attach as children/annotations automatically.
5004
+ */
5005
+ async function withLlmObsSpan(options, fn) {
5006
+ const sdk = activeSdk();
5007
+ if (!sdk) {
5008
+ return fn();
5009
+ }
5010
+ let started = false;
5011
+ try {
5012
+ return (await sdk.trace(options, () => {
5013
+ started = true;
5014
+ return fn();
5015
+ }));
5016
+ }
5017
+ catch (error) {
5018
+ // If `fn` ran, propagate its error (the SDK re-throws after finishing the
5019
+ // span). Only swallow failures from the instrumentation itself, re-running
5020
+ // `fn` so observability never breaks the underlying call.
5021
+ if (started) {
5022
+ throw error;
5023
+ }
5024
+ getLogger$6().warn("[llmobs] span emission failed");
5025
+ getLogger$6().var({ error });
5026
+ return fn();
5027
+ }
5028
+ }
5029
+ /**
5030
+ * Annotate the currently active LLM Observability span. No-op when disabled.
5031
+ */
5032
+ function annotateLlmObs(annotation) {
5033
+ const sdk = activeSdk();
5034
+ if (!sdk) {
5035
+ return;
5036
+ }
5037
+ try {
5038
+ sdk.annotate(undefined, annotation);
5039
+ }
5040
+ catch (error) {
5041
+ getLogger$6().warn("[llmobs] annotate failed");
5042
+ getLogger$6().var({ error });
5043
+ }
5044
+ }
5045
+ /**
5046
+ * Open a manual span that is finished later. Used by streaming, where work is
5047
+ * yielded incrementally and cannot be enclosed in a single callback. The span
5048
+ * is kept open via a deferred promise resolved by `finish()`. Returns null when
5049
+ * emission is disabled or dd-trace is absent.
5050
+ *
5051
+ * Note: because the span is held open across `yield` boundaries (outside the
5052
+ * SDK's AsyncLocalStorage scope), manual spans are emitted flat rather than
5053
+ * nested. Annotations are applied to the explicit span reference.
5054
+ */
5055
+ function openLlmObsSpan(options) {
5056
+ const sdk = activeSdk();
5057
+ if (!sdk) {
5058
+ return null;
5059
+ }
5060
+ try {
5061
+ let capturedSpan;
5062
+ let release;
5063
+ const held = new Promise((resolve) => {
5064
+ release = resolve;
5065
+ });
5066
+ // trace() runs the callback synchronously, capturing the span; the returned
5067
+ // pending promise keeps the span open until finish() resolves it.
5068
+ void sdk.trace(options, (span) => {
5069
+ capturedSpan = span;
5070
+ return held;
5071
+ });
5072
+ let finished = false;
5073
+ return {
5074
+ annotate: (annotation) => {
5075
+ try {
5076
+ sdk.annotate(capturedSpan, annotation);
5077
+ }
5078
+ catch (error) {
5079
+ getLogger$6().warn("[llmobs] annotate failed");
5080
+ getLogger$6().var({ error });
5081
+ }
5082
+ },
5083
+ finish: () => {
5084
+ if (finished) {
5085
+ return;
5086
+ }
5087
+ finished = true;
5088
+ release?.();
5089
+ },
5090
+ };
5091
+ }
5092
+ catch (error) {
5093
+ getLogger$6().warn("[llmobs] span emission failed");
5094
+ getLogger$6().var({ error });
5095
+ return null;
5096
+ }
5097
+ }
5098
+ //
5099
+ //
5100
+ // Mapping Helpers
5101
+ //
5102
+ /**
5103
+ * Sum an `LlmUsage` array into LLM Observability metric keys. Returns undefined
5104
+ * when there is no usage data so callers can omit the `metrics` field.
5105
+ */
5106
+ function usageToLlmObsMetrics(usage) {
5107
+ if (!usage || usage.length === 0) {
5108
+ return undefined;
5109
+ }
5110
+ const metrics = usage.reduce((sum, item) => ({
5111
+ input_tokens: sum.input_tokens + (item.input ?? 0),
5112
+ output_tokens: sum.output_tokens + (item.output ?? 0),
5113
+ total_tokens: sum.total_tokens + (item.total ?? 0),
5114
+ }), { input_tokens: 0, output_tokens: 0, total_tokens: 0 });
5115
+ return metrics;
5116
+ }
5117
+
4838
5118
  //
4839
5119
  //
4840
5120
  // Main
@@ -5706,30 +5986,46 @@ class OperateLoop {
5706
5986
  // Initialize state
5707
5987
  const state = await this.initializeState(input, options);
5708
5988
  const context = this.createContext(options);
5709
- // Build initial request
5710
- let request = this.buildInitialRequest(state, options);
5711
- // Multi-turn loop
5712
- while (state.currentTurn < state.maxTurns) {
5713
- state.currentTurn++;
5714
- // Execute one turn with retry logic
5715
- const shouldContinue = await this.executeOneTurn(request, state, context, options);
5716
- if (!shouldContinue) {
5717
- break;
5989
+ const modelName = options.model ?? this.adapter.defaultModel;
5990
+ // Enclosing LLM Observability span (no-op when DD_LLMOBS_ENABLED is unset).
5991
+ // Child llm/tool spans nest under it via the SDK's active-span context.
5992
+ return withLlmObsSpan({
5993
+ kind: state.toolkit ? "agent" : "llm",
5994
+ modelName,
5995
+ modelProvider: this.adapter.name,
5996
+ name: "jaypie.llm.operate",
5997
+ }, async () => {
5998
+ // Build initial request
5999
+ let request = this.buildInitialRequest(state, options);
6000
+ // Multi-turn loop
6001
+ while (state.currentTurn < state.maxTurns) {
6002
+ state.currentTurn++;
6003
+ // Execute one turn with retry logic
6004
+ const shouldContinue = await this.executeOneTurn(request, state, context, options);
6005
+ if (!shouldContinue) {
6006
+ break;
6007
+ }
6008
+ // Rebuild request with updated history for next turn
6009
+ request = {
6010
+ format: state.formattedFormat,
6011
+ instructions: options.instructions,
6012
+ messages: state.currentInput,
6013
+ model: modelName,
6014
+ providerOptions: options.providerOptions,
6015
+ system: options.system,
6016
+ temperature: options.temperature,
6017
+ tools: state.formattedTools,
6018
+ user: options.user,
6019
+ };
5718
6020
  }
5719
- // Rebuild request with updated history for next turn
5720
- request = {
5721
- format: state.formattedFormat,
5722
- instructions: options.instructions,
5723
- messages: state.currentInput,
5724
- model: options.model ?? this.adapter.defaultModel,
5725
- providerOptions: options.providerOptions,
5726
- system: options.system,
5727
- temperature: options.temperature,
5728
- tools: state.formattedTools,
5729
- user: options.user,
5730
- };
5731
- }
5732
- return state.responseBuilder.build();
6021
+ const response = state.responseBuilder.build();
6022
+ annotateLlmObs({
6023
+ inputData: input,
6024
+ metrics: usageToLlmObsMetrics(response.usage),
6025
+ outputData: response.content,
6026
+ });
6027
+ return response;
6028
+ });
5733
6029
  }
5734
6030
  //
5735
6031
  // Private Methods
@@ -5831,20 +6127,34 @@ class OperateLoop {
5831
6127
  options,
5832
6128
  providerRequest,
5833
6129
  });
5834
- // Execute with retry (RetryExecutor handles error hooks and throws appropriate errors)
5835
- const response = await retryExecutor.execute((signal) => this.adapter.executeRequest(this.client, providerRequest, signal), {
5836
- context: {
5837
- input: state.currentInput,
5838
- options,
5839
- providerRequest,
5840
- },
5841
- hooks: context.hooks,
6130
+ // Execute with retry inside a child llm span (no-op when llmobs disabled).
6131
+ // RetryExecutor handles error hooks and throws appropriate errors.
6132
+ const { parsed, response } = await withLlmObsSpan({
6133
+ kind: "llm",
6134
+ modelName: options.model ?? this.adapter.defaultModel,
6135
+ modelProvider: this.adapter.name,
6136
+ name: "jaypie.llm.model",
6137
+ }, async () => {
6138
+ const response = await retryExecutor.execute((signal) => this.adapter.executeRequest(this.client, providerRequest, signal), {
6139
+ context: {
6140
+ input: state.currentInput,
6141
+ options,
6142
+ providerRequest,
6143
+ },
6144
+ hooks: context.hooks,
6145
+ });
6146
+ // Log what was returned from the model
6147
+ log.trace("[operate] Model response received");
6148
+ log.var({ "operate.response": response });
6149
+ // Parse response
6150
+ const parsed = this.adapter.parseResponse(response, options);
6151
+ annotateLlmObs({
6152
+ inputData: state.currentInput,
6153
+ metrics: usageToLlmObsMetrics(parsed.usage ? [parsed.usage] : undefined),
6154
+ outputData: parsed.content ?? "",
6155
+ });
6156
+ return { parsed, response };
5842
6157
  });
5843
- // Log what was returned from the model
5844
- log.trace("[operate] Model response received");
5845
- log.var({ "operate.response": response });
5846
- // Parse response
5847
- const parsed = this.adapter.parseResponse(response, options);
5848
6158
  // Track usage
5849
6159
  if (parsed.usage) {
5850
6160
  state.responseBuilder.addUsage(parsed.usage);
@@ -5865,7 +6175,7 @@ class OperateLoop {
5865
6175
  if (this.adapter.hasStructuredOutput(response)) {
5866
6176
  const structuredOutput = this.adapter.extractStructuredOutput(response);
5867
6177
  if (structuredOutput) {
5868
- state.responseBuilder.setContent(structuredOutput);
6178
+ state.responseBuilder.setContent(this.applyFormatArrayDefaults(structuredOutput, options));
5869
6179
  state.responseBuilder.complete();
5870
6180
  return false; // Stop loop
5871
6181
  }
@@ -5889,11 +6199,19 @@ class OperateLoop {
5889
6199
  args: toolCall.arguments,
5890
6200
  toolName: toolCall.name,
5891
6201
  });
5892
- // Call the tool
6202
+ // Call the tool inside a child tool span (no-op when disabled)
5893
6203
  log.trace(`[operate] Calling tool - ${toolCall.name}`);
5894
- const result = await state.toolkit.call({
5895
- arguments: toolCall.arguments,
5896
- name: toolCall.name,
6204
+ const result = await withLlmObsSpan({ kind: "tool", name: toolCall.name }, async () => {
6205
+ const result = await state.toolkit.call({
6206
+ arguments: toolCall.arguments,
6207
+ name: toolCall.name,
6208
+ });
6209
+ annotateLlmObs({
6210
+ inputData: toolCall.arguments,
6211
+ metadata: { tool: toolCall.name },
6212
+ outputData: result,
6213
+ });
6214
+ return result;
5897
6215
  });
5898
6216
  // Execute afterEachTool hook
5899
6217
  await this.hookRunnerInstance.runAfterTool(context.hooks, {
@@ -5982,7 +6300,7 @@ class OperateLoop {
5982
6300
  }
5983
6301
  }
5984
6302
  // No tool calls or no toolkit - we're done
5985
- state.responseBuilder.setContent(parsed.content);
6303
+ state.responseBuilder.setContent(this.applyFormatArrayDefaults(parsed.content, options));
5986
6304
  state.responseBuilder.complete();
5987
6305
  // Add final history items
5988
6306
  const historyItems = this.adapter.responseToHistoryItems(parsed.raw);
@@ -5991,6 +6309,20 @@ class OperateLoop {
5991
6309
  }
5992
6310
  return false; // Stop loop
5993
6311
  }
6312
+ /**
6313
+ * Backfill declared array fields when a `format` is supplied. A declared
6314
+ * `format` is a schema contract: an empty array field should surface as `[]`
6315
+ * rather than be dropped by a provider/model that omits empty lists.
6316
+ */
6317
+ applyFormatArrayDefaults(content, options) {
6318
+ if (!options.format) {
6319
+ return content;
6320
+ }
6321
+ if (typeof content !== "object" || content === null) {
6322
+ return content;
6323
+ }
6324
+ return fillFormatArrays({ content, format: options.format });
6325
+ }
5994
6326
  /**
5995
6327
  * Sync the current input state from the updated provider request.
5996
6328
  * This is necessary because appendToolResult modifies the provider-specific request,
@@ -6143,7 +6475,7 @@ class StreamLoop {
6143
6475
  }
6144
6476
  // If we have tool calls, process them
6145
6477
  if (toolCalls && toolCalls.length > 0 && state.toolkit) {
6146
- yield* this.processToolCalls(toolCalls, state, context, options);
6478
+ yield* this.processToolCalls(toolCalls, state, context);
6147
6479
  // Check if we've reached max turns
6148
6480
  if (state.currentTurn >= state.maxTurns) {
6149
6481
  const error = new TooManyRequestsError();
@@ -6250,6 +6582,16 @@ class StreamLoop {
6250
6582
  options,
6251
6583
  providerRequest,
6252
6584
  });
6585
+ // Open a manual llm span held open across the streamed turn. Flat (not
6586
+ // nested) because it spans yield boundaries; no-op when llmobs disabled.
6587
+ const llmSpan = openLlmObsSpan({
6588
+ kind: "llm",
6589
+ modelName: options.model ?? this.adapter.defaultModel,
6590
+ modelProvider: this.adapter.name,
6591
+ name: "jaypie.llm.model",
6592
+ });
6593
+ const inputSnapshot = [...state.currentInput];
6594
+ let streamedText = "";
6253
6595
  // Collect tool calls from the stream
6254
6596
  const collectedToolCalls = [];
6255
6597
  // Retry loop for connection-level failures
@@ -6269,6 +6611,7 @@ class StreamLoop {
6269
6611
  // Pass through text chunks
6270
6612
  if (chunk.type === LlmStreamChunkType.Text) {
6271
6613
  chunksYielded = true;
6614
+ streamedText += chunk.content ?? "";
6272
6615
  yield chunk;
6273
6616
  }
6274
6617
  // Collect tool calls
@@ -6315,6 +6658,12 @@ class StreamLoop {
6315
6658
  title: "Stream Error",
6316
6659
  },
6317
6660
  };
6661
+ llmSpan?.annotate({
6662
+ inputData: inputSnapshot,
6663
+ metrics: usageToLlmObsMetrics(state.usageItems),
6664
+ outputData: streamedText,
6665
+ });
6666
+ llmSpan?.finish();
6318
6667
  return { shouldContinue: false };
6319
6668
  }
6320
6669
  // Check if we've exhausted retries or error is not retryable
@@ -6323,6 +6672,7 @@ class StreamLoop {
6323
6672
  log.error(`Stream request failed after ${this.retryPolicy.maxRetries} retries`);
6324
6673
  log.var({ error });
6325
6674
  const errorMessage = error instanceof Error ? error.message : String(error);
6675
+ llmSpan?.finish();
6326
6676
  throw new BadGatewayError(errorMessage);
6327
6677
  }
6328
6678
  const delay = this.retryPolicy.getDelayForAttempt(attempt);
@@ -6336,6 +6686,13 @@ class StreamLoop {
6336
6686
  finally {
6337
6687
  guard.remove();
6338
6688
  }
6689
+ // Annotate and finish the streamed llm span (no-op when disabled)
6690
+ llmSpan?.annotate({
6691
+ inputData: inputSnapshot,
6692
+ metrics: usageToLlmObsMetrics(state.usageItems),
6693
+ outputData: streamedText,
6694
+ });
6695
+ llmSpan?.finish();
6339
6696
  // Execute afterEachModelResponse hook
6340
6697
  await this.hookRunnerInstance.runAfterModelResponse(context.hooks, {
6341
6698
  content: "",
@@ -6370,7 +6727,7 @@ class StreamLoop {
6370
6727
  }
6371
6728
  return { shouldContinue: false };
6372
6729
  }
6373
- async *processToolCalls(toolCalls, state, context, _options) {
6730
+ async *processToolCalls(toolCalls, state, context) {
6374
6731
  const log = getLogger$6();
6375
6732
  for (const toolCall of toolCalls) {
6376
6733
  try {
@@ -6379,11 +6736,19 @@ class StreamLoop {
6379
6736
  args: toolCall.arguments,
6380
6737
  toolName: toolCall.name,
6381
6738
  });
6382
- // Call the tool
6739
+ // Call the tool inside a child tool span (no-op when disabled)
6383
6740
  log.trace(`[stream] Calling tool - ${toolCall.name}`);
6384
- const result = await state.toolkit.call({
6385
- arguments: toolCall.arguments,
6386
- name: toolCall.name,
6741
+ const result = await withLlmObsSpan({ kind: "tool", name: toolCall.name }, async () => {
6742
+ const result = await state.toolkit.call({
6743
+ arguments: toolCall.arguments,
6744
+ name: toolCall.name,
6745
+ });
6746
+ annotateLlmObs({
6747
+ inputData: toolCall.arguments,
6748
+ metadata: { tool: toolCall.name },
6749
+ outputData: result,
6750
+ });
6751
+ return result;
6387
6752
  });
6388
6753
  // Execute afterEachTool hook
6389
6754
  await this.hookRunnerInstance.runAfterTool(context.hooks, {