@tanstack/ai 0.32.0 → 0.34.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 (64) hide show
  1. package/dist/esm/activities/chat/index.js +47 -20
  2. package/dist/esm/activities/chat/index.js.map +1 -1
  3. package/dist/esm/activities/chat/middleware/types.d.ts +7 -0
  4. package/dist/esm/activities/chat/tools/lazy-tool-manager.d.ts +25 -1
  5. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +26 -2
  6. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  7. package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -0
  8. package/dist/esm/activities/chat/tools/schema-converter.js +61 -33
  9. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  10. package/dist/esm/activities/generateAudio/index.d.ts +7 -0
  11. package/dist/esm/activities/generateAudio/index.js +26 -1
  12. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  13. package/dist/esm/activities/generateImage/index.d.ts +7 -0
  14. package/dist/esm/activities/generateImage/index.js +26 -1
  15. package/dist/esm/activities/generateImage/index.js.map +1 -1
  16. package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
  17. package/dist/esm/activities/generateSpeech/index.js +26 -1
  18. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  19. package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
  20. package/dist/esm/activities/generateTranscription/index.js +26 -1
  21. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  22. package/dist/esm/activities/generateVideo/index.d.ts +9 -0
  23. package/dist/esm/activities/generateVideo/index.js +52 -2
  24. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  25. package/dist/esm/activities/middleware/index.d.ts +2 -0
  26. package/dist/esm/activities/middleware/run.d.ts +20 -0
  27. package/dist/esm/activities/middleware/run.js +42 -0
  28. package/dist/esm/activities/middleware/run.js.map +1 -0
  29. package/dist/esm/activities/middleware/types.d.ts +118 -0
  30. package/dist/esm/index.d.ts +2 -0
  31. package/dist/esm/index.js +2 -0
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/middlewares/otel.d.ts +8 -2
  34. package/dist/esm/middlewares/otel.js +145 -95
  35. package/dist/esm/middlewares/otel.js.map +1 -1
  36. package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
  37. package/dist/esm/middlewares/usage-attributes.js +43 -0
  38. package/dist/esm/middlewares/usage-attributes.js.map +1 -0
  39. package/dist/esm/types.d.ts +7 -7
  40. package/dist/esm/utilities/errors.d.ts +13 -0
  41. package/dist/esm/utilities/errors.js +22 -0
  42. package/dist/esm/utilities/errors.js.map +1 -0
  43. package/dist/esm/utilities/numbers.d.ts +8 -0
  44. package/dist/esm/utilities/numbers.js +12 -0
  45. package/dist/esm/utilities/numbers.js.map +1 -0
  46. package/package.json +3 -2
  47. package/src/activities/chat/index.ts +125 -35
  48. package/src/activities/chat/middleware/types.ts +7 -0
  49. package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
  50. package/src/activities/chat/tools/schema-converter.ts +146 -93
  51. package/src/activities/generateAudio/index.ts +42 -1
  52. package/src/activities/generateImage/index.ts +42 -1
  53. package/src/activities/generateSpeech/index.ts +42 -1
  54. package/src/activities/generateTranscription/index.ts +42 -1
  55. package/src/activities/generateVideo/index.ts +88 -2
  56. package/src/activities/middleware/index.ts +20 -0
  57. package/src/activities/middleware/run.ts +88 -0
  58. package/src/activities/middleware/types.ts +173 -0
  59. package/src/index.ts +19 -0
  60. package/src/middlewares/otel.ts +195 -120
  61. package/src/middlewares/usage-attributes.ts +65 -0
  62. package/src/types.ts +7 -7
  63. package/src/utilities/errors.ts +29 -0
  64. package/src/utilities/numbers.ts +15 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { devtoolsMiddleware } from '@tanstack/ai-event-client'
9
+ import { undoNullWidening } from '@tanstack/ai-utils'
9
10
  import { stripToSpecMiddleware } from '../../strip-to-spec-middleware'
10
11
  import { streamToText } from '../../stream-to-response.js'
11
12
  import { resolveDebugOption } from '../../logger/resolve'
@@ -18,6 +19,7 @@ import {
18
19
  executeToolCalls,
19
20
  } from './tools/tool-calls'
20
21
  import {
22
+ convertSchemaForStructuredOutput,
21
23
  convertSchemaToJsonSchema,
22
24
  isStandardSchema,
23
25
  parseWithStandardSchema,
@@ -33,7 +35,11 @@ import type {
33
35
  ClientToolRequest,
34
36
  ToolResult,
35
37
  } from './tools/tool-calls'
36
- import type { AnyTextAdapter, StructuredOutputOptions } from './adapter'
38
+ import type {
39
+ AnyTextAdapter,
40
+ StructuredOutputOptions,
41
+ StructuredOutputResult,
42
+ } from './adapter'
37
43
  import type {
38
44
  AgentLoopStrategy,
39
45
  AnyTool,
@@ -418,11 +424,21 @@ interface TextEngineConfig<
418
424
  * (used by runStreamingStructuredOutput). When false, chunks are
419
425
  * consumed internally for middleware visibility but not yielded
420
426
  * (used by runAgenticStructuredOutput).
421
- * - validate: optional callback invoked AFTER the structured-output result
422
- * is captured but BEFORE the terminal hook fires. If it throws, the
423
- * engine records a `finalizationError` and fires `onError` instead of
424
- * `onFinish` (per spec §7.3). On success, the returned value is stored
425
- * as the validated result and retrievable via
427
+ * - normalize: optional schema-aware transform applied to the captured
428
+ * structured-output object the moment it enters the engine — BEFORE it is
429
+ * stored, validated, or yielded. Used to undo strict-mode null-widening
430
+ * (`undoNullWidening`): strict schemas widen optional fields to
431
+ * `required` + nullable so the provider returns `null` for an absent
432
+ * optional, and this strips exactly those synthesized nulls while keeping
433
+ * the ones a `.nullable()` field genuinely allows. Applied here (not in
434
+ * the adapter) because the engine is the only layer holding the original
435
+ * schema's null-widening map, and applying it at capture fixes BOTH the
436
+ * streaming chunk and the Promise<T> result with one transform.
437
+ * - validate: optional callback invoked AFTER `normalize` and AFTER the
438
+ * structured-output result is captured, but BEFORE the terminal hook
439
+ * fires. If it throws, the engine records a `finalizationError` and fires
440
+ * `onError` instead of `onFinish` (per spec §7.3). On success, the
441
+ * returned value is stored as the validated result and retrievable via
426
442
  * `getValidatedStructuredOutput()`. Used by `runAgenticStructuredOutput`
427
443
  * to perform Standard Schema validation inside the engine.
428
444
  * - nativeCombined: when true, the adapter declared
@@ -437,6 +453,7 @@ interface TextEngineConfig<
437
453
  finalStructuredOutput?: {
438
454
  jsonSchema: JSONSchema
439
455
  yieldChunks: boolean
456
+ normalize?: (data: unknown) => unknown
440
457
  validate?: (data: unknown) => unknown
441
458
  nativeCombined?: boolean
442
459
  }
@@ -542,8 +559,8 @@ class TextEngine<
542
559
  // to carry, so the client matches it to the streaming text deltas.
543
560
  private combinedStructuredMessageId: string | null = null
544
561
  // Holds the validated value when `finalStructuredOutput.validate` is provided
545
- // and succeeds. Distinct from `structuredOutputResult.data` (the raw,
546
- // unvalidated payload from the structured-output.complete chunk).
562
+ // and succeeds. Distinct from `structuredOutputResult.data` (the normalized
563
+ // but unvalidated payload from the structured-output.complete chunk).
547
564
  private validatedStructuredOutput: unknown = undefined
548
565
  private hasValidatedStructuredOutput = false
549
566
  private finalizationError: {
@@ -554,6 +571,7 @@ class TextEngine<
554
571
  private readonly finalStructuredOutput?: {
555
572
  jsonSchema: JSONSchema
556
573
  yieldChunks: boolean
574
+ normalize?: (data: unknown) => unknown
557
575
  validate?: (data: unknown) => unknown
558
576
  nativeCombined?: boolean
559
577
  }
@@ -646,6 +664,7 @@ class TextEngine<
646
664
  this.deferredPromises.push(promise)
647
665
  },
648
666
  // Provider / adapter info
667
+ activity: 'chat',
649
668
  provider: config.adapter.name,
650
669
  model: config.params.model,
651
670
  source: 'server',
@@ -1197,6 +1216,23 @@ class TextEngine<
1197
1216
  }
1198
1217
  }
1199
1218
 
1219
+ /**
1220
+ * Tools available for execution this turn. The discovery tool is dropped
1221
+ * from the advertised set (`this.tools`) once every lazy tool is discovered,
1222
+ * but a model may still re-request discovery; this widens execution lookup
1223
+ * to include it so such calls don't fail with "Unknown tool". Centralised so
1224
+ * both execution sites (`processToolCalls` and `checkForPendingToolCalls`)
1225
+ * stay in sync.
1226
+ */
1227
+ private resolveExecutableTools(
1228
+ toolCalls: ReadonlyArray<ToolCall>,
1229
+ ): ReadonlyArray<AnyTool> {
1230
+ return this.lazyToolManager.getExecutableTools(
1231
+ this.tools,
1232
+ toolCalls.map((tc) => tc.function.name),
1233
+ )
1234
+ }
1235
+
1200
1236
  private async *checkForPendingToolCalls(): AsyncGenerator<
1201
1237
  StreamChunk,
1202
1238
  ToolPhaseResult,
@@ -1245,7 +1281,7 @@ class TextEngine<
1245
1281
 
1246
1282
  const generator = executeToolCalls(
1247
1283
  executablePendingCalls,
1248
- this.tools,
1284
+ this.resolveExecutableTools(executablePendingCalls),
1249
1285
  approvals,
1250
1286
  clientToolResults,
1251
1287
  (eventName, data) => this.createCustomEventChunk(eventName, data),
@@ -1407,7 +1443,7 @@ class TextEngine<
1407
1443
 
1408
1444
  const generator = executeToolCalls(
1409
1445
  executableToolCalls,
1410
- this.tools,
1446
+ this.resolveExecutableTools(executableToolCalls),
1411
1447
  approvals,
1412
1448
  clientToolResults,
1413
1449
  (eventName, data) => this.createCustomEventChunk(eventName, data),
@@ -2065,15 +2101,29 @@ class TextEngine<
2065
2101
  // All narrowing below is via the discriminated-union `chunk.type`
2066
2102
  // — no `as` casts.
2067
2103
 
2104
+ // The chunk forwarded to middleware/consumers. Replaced below only for
2105
+ // the structured-output.complete event, whose `object` we normalize
2106
+ // (un-widen) so streaming consumers see the same cleaned payload the
2107
+ // Promise<T> path validates and returns.
2108
+ let outboundChunk: StreamChunk = chunk
2109
+
2068
2110
  if (
2069
2111
  chunk.type === EventType.CUSTOM &&
2070
2112
  chunk.name === 'structured-output.complete'
2071
2113
  ) {
2072
2114
  const parsed = readStructuredOutputCompleteValue(chunk.value)
2073
2115
  if (parsed) {
2074
- this.structuredOutputResult = {
2075
- data: parsed.object,
2076
- rawText: parsed.raw,
2116
+ const object = this.finalStructuredOutput.normalize
2117
+ ? this.finalStructuredOutput.normalize(parsed.object)
2118
+ : parsed.object
2119
+ this.structuredOutputResult = { data: object, rawText: parsed.raw }
2120
+ // Rewrite the outbound event so the yielded chunk carries the
2121
+ // normalized object (the original `chunk.value` still holds the
2122
+ // widened one). Preserve every other field — `raw`, `reasoning` —
2123
+ // by spreading the original value.
2124
+ const value = chunk.value
2125
+ if (object !== parsed.object && value && typeof value === 'object') {
2126
+ outboundChunk = { ...chunk, value: { ...value, object } }
2077
2127
  }
2078
2128
  }
2079
2129
  }
@@ -2097,7 +2147,7 @@ class TextEngine<
2097
2147
  // 7b. Pipe through middleware
2098
2148
  const outputChunks = await this.middlewareRunner.runOnChunk(
2099
2149
  this.middlewareCtx,
2100
- chunk,
2150
+ outboundChunk,
2101
2151
  )
2102
2152
 
2103
2153
  // 7c. Decide consumer visibility — only yieldChunks=true callers get them.
@@ -2254,7 +2304,14 @@ class TextEngine<
2254
2304
  } else {
2255
2305
  try {
2256
2306
  const parsed: unknown = JSON.parse(rawText)
2257
- this.structuredOutputResult = { data: parsed, rawText }
2307
+ // Normalize (un-widen) before storing so the synthesized
2308
+ // structured-output.complete chunk and the Promise<T> result both
2309
+ // carry the cleaned payload. JSON.parse preserves provider nulls, so
2310
+ // this is where native-combined output gets its widening undone.
2311
+ const data = this.finalStructuredOutput.normalize
2312
+ ? this.finalStructuredOutput.normalize(parsed)
2313
+ : parsed
2314
+ this.structuredOutputResult = { data, rawText }
2258
2315
  } catch (err: unknown) {
2259
2316
  const detail =
2260
2317
  rawText.slice(0, 200) + (rawText.length > 200 ? '...' : '')
@@ -2711,18 +2768,31 @@ async function runAgenticStructuredOutput<
2711
2768
 
2712
2769
  // Same strict-conversion as the streaming path (`forStructuredOutput: true`)
2713
2770
  // so the same Zod schema produces the same JSON Schema regardless of
2714
- // stream mode — Promise<T> and stream:true must not diverge here.
2715
- const jsonSchema = convertSchemaToJsonSchema(outputSchema, {
2716
- forStructuredOutput: true,
2717
- })
2771
+ // stream mode — Promise<T> and stream:true must not diverge here. The same
2772
+ // pass also records a `nullWideningMap`: optional fields are widened to
2773
+ // `required` + nullable for the provider, which then returns `null` for an
2774
+ // absent optional — a `null` the original `.optional()` (`T | undefined`)
2775
+ // schema would otherwise reject. The map pinpoints exactly those synthesized
2776
+ // nulls so `undoNullWidening` can drop them while preserving the ones a
2777
+ // `.nullable()` field genuinely allows.
2778
+ const { jsonSchema, nullWideningMap } =
2779
+ convertSchemaForStructuredOutput(outputSchema)
2718
2780
  if (!jsonSchema) {
2719
2781
  throw new Error('Failed to convert output schema to JSON Schema')
2720
2782
  }
2721
2783
 
2784
+ // Un-widening runs in the engine the moment the structured output is
2785
+ // captured (`finalStructuredOutput.normalize`), so it applies uniformly to
2786
+ // every adapter and to both stream modes — the engine is the only layer
2787
+ // holding the schema's `nullWideningMap`. Validation then runs on the
2788
+ // already-normalized data, so `validate` is a plain Standard Schema parse.
2789
+ const normalize = (data: unknown): unknown =>
2790
+ undoNullWidening(data, nullWideningMap)
2791
+
2722
2792
  // Validation runs INSIDE the engine (per spec §7.3) so validation failures
2723
2793
  // route through the engine's terminal-hook chooser as `onError`. We pass a
2724
2794
  // `validate` callback when the schema is a Standard Schema; otherwise we
2725
- // pass through the raw data and the engine returns it unchanged.
2795
+ // pass through the (normalized) data and the engine returns it unchanged.
2726
2796
  const validate = isStandardSchema(outputSchema)
2727
2797
  ? (data: unknown): unknown =>
2728
2798
  parseWithStandardSchema<InferSchemaType<TSchema>>(outputSchema, data)
@@ -2754,6 +2824,7 @@ async function runAgenticStructuredOutput<
2754
2824
  finalStructuredOutput: {
2755
2825
  jsonSchema,
2756
2826
  yieldChunks: false,
2827
+ normalize,
2757
2828
  ...(validate ? { validate } : {}),
2758
2829
  ...(nativeCombined ? { nativeCombined: true } : {}),
2759
2830
  },
@@ -2861,7 +2932,7 @@ async function* fallbackStructuredOutputStream(
2861
2932
  timestamp,
2862
2933
  }
2863
2934
 
2864
- let result: { data: unknown; rawText: string }
2935
+ let result: StructuredOutputResult<unknown>
2865
2936
  try {
2866
2937
  result = await adapter.structuredOutput(options)
2867
2938
  } catch (error) {
@@ -2917,6 +2988,12 @@ async function* fallbackStructuredOutputStream(
2917
2988
  model,
2918
2989
  timestamp,
2919
2990
  finishReason: 'stop',
2991
+ // Forward adapter-reported token usage so consumers reading
2992
+ // `RUN_FINISHED.usage` (and the engine's `runOnUsage` middleware hook) see
2993
+ // it on the fallback path, mirroring the native streaming path. The
2994
+ // conditional spread avoids emitting `usage: undefined` for adapters that
2995
+ // don't report it. See #758.
2996
+ ...(result.usage ? { usage: result.usage } : {}),
2920
2997
  }
2921
2998
  }
2922
2999
 
@@ -2926,17 +3003,23 @@ async function* fallbackStructuredOutputStream(
2926
3003
  * RUN_STARTED/RUN_FINISHED are suppressed; the structured-output finalization
2927
3004
  * step's pair brackets the run for the consumer.
2928
3005
  *
2929
- * Schema validation is intentionally NOT run on this path — it is the
2930
- * consumer's responsibility. The `structured-output.complete` CUSTOM event
2931
- * is forwarded with the adapter-produced `value.object` as-is. This is a
2932
- * deliberate asymmetry vs. `runAgenticStructuredOutput` (Promise<T> path),
2933
- * which DOES run Standard Schema validation inside the engine and routes
2934
- * validation failures through `onError`. The reason for the asymmetry:
3006
+ * Standard Schema *validation* is intentionally NOT run on this path — it is
3007
+ * the consumer's responsibility. This is a deliberate asymmetry vs.
3008
+ * `runAgenticStructuredOutput` (Promise<T> path), which DOES validate inside
3009
+ * the engine and routes validation failures through `onError`. The reason:
2935
3010
  * streaming consumers typically render partial JSON progressively (via
2936
3011
  * `parsePartialJSON` or `useChat`'s `partial` slot) and validate downstream
2937
3012
  * after assembly. Running validation server-side would force a hard error
2938
3013
  * on partial-by-design payloads. See `docs/structured-outputs/overview.md`.
2939
3014
  *
3015
+ * Null-widening normalization, however, IS run on both paths: the
3016
+ * `structured-output.complete` CUSTOM event is forwarded with its `value.object`
3017
+ * already un-widened (synthesized strict-mode nulls dropped, genuine
3018
+ * `.nullable()` nulls kept), so a consumer validating the assembled object
3019
+ * against the original schema doesn't choke on a `null` for an `.optional()`
3020
+ * field. Same `convertSchemaForStructuredOutput` pass and same
3021
+ * `undoNullWidening` map as the Promise<T> path — the two must not diverge.
3022
+ *
2940
3023
  * Pre-flight validation (missing schema, unconvertible schema) throws
2941
3024
  * synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
2942
3025
  * those are programmer errors, not runtime conditions.
@@ -2954,14 +3037,17 @@ function runStreamingStructuredOutput<
2954
3037
  }
2955
3038
 
2956
3039
  // forStructuredOutput strict-converts the schema once at the activity
2957
- // boundary. Adapters can re-convert if their wire format diverges, but the
2958
- // default flow hands them a strict-ready schema.
2959
- const jsonSchema = convertSchemaToJsonSchema(outputSchema, {
2960
- forStructuredOutput: true,
2961
- })
3040
+ // boundary, capturing the null-widening map so the engine can un-widen the
3041
+ // provider's response before it reaches the consumer. Adapters can re-convert
3042
+ // if their wire format diverges, but the default flow hands them a
3043
+ // strict-ready schema.
3044
+ const { jsonSchema, nullWideningMap } =
3045
+ convertSchemaForStructuredOutput(outputSchema)
2962
3046
  if (!jsonSchema) {
2963
3047
  throw new Error('Failed to convert output schema to JSON Schema')
2964
3048
  }
3049
+ const normalize = (data: unknown): unknown =>
3050
+ undoNullWidening(data, nullWideningMap)
2965
3051
 
2966
3052
  // The implementation generator yields the broader internal type
2967
3053
  // (`StreamChunk | StructuredOutputCompleteEvent<T>`) so agent-loop
@@ -2972,6 +3058,7 @@ function runStreamingStructuredOutput<
2972
3058
  return runStreamingStructuredOutputImpl(
2973
3059
  options,
2974
3060
  jsonSchema,
3061
+ normalize,
2975
3062
  ) as StructuredOutputStream<InferSchemaType<TSchema>>
2976
3063
  }
2977
3064
 
@@ -2997,6 +3084,7 @@ async function* runStreamingStructuredOutputImpl<
2997
3084
  >(
2998
3085
  options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
2999
3086
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
3087
+ normalize: (data: unknown) => unknown,
3000
3088
  ): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
3001
3089
  const {
3002
3090
  adapter,
@@ -3041,6 +3129,7 @@ async function* runStreamingStructuredOutputImpl<
3041
3129
  finalStructuredOutput: {
3042
3130
  jsonSchema,
3043
3131
  yieldChunks: true,
3132
+ normalize,
3044
3133
  ...(nativeCombined ? { nativeCombined: true } : {}),
3045
3134
  },
3046
3135
  },
@@ -3055,9 +3144,10 @@ async function* runStreamingStructuredOutputImpl<
3055
3144
  await mcpManager.dispose()
3056
3145
  }
3057
3146
 
3058
- // Schema validation for the streaming variant remains the consumer's
3059
- // responsibility — they read the CUSTOM 'structured-output.complete' from
3060
- // the yielded stream. Matches pre-fix behavior.
3147
+ // Standard Schema validation for the streaming variant remains the
3148
+ // consumer's responsibility — they read the CUSTOM 'structured-output.complete'
3149
+ // from the yielded stream. (Null-widening normalization, by contrast, already
3150
+ // ran inside the engine via `normalize`, so the object they read is un-widened.)
3061
3151
  void outputSchema
3062
3152
  }
3063
3153
 
@@ -80,6 +80,13 @@ export interface ChatMiddlewareContext<TContext = unknown> {
80
80
 
81
81
  // --- Provider / adapter info (immutable for the lifetime of the request) ---
82
82
 
83
+ /**
84
+ * Which activity this context describes — always `'chat'`. Present so the
85
+ * chat context structurally satisfies the base `GenerationMiddlewareContext`,
86
+ * letting an observe-only middleware authored against the base (e.g.
87
+ * `otelMiddleware`) run on both chat and media activities.
88
+ */
89
+ activity: 'chat'
83
90
  /** Provider name (e.g., 'openai', 'anthropic') */
84
91
  provider: string
85
92
  /** Model identifier (e.g., 'gpt-4o') */
@@ -1,7 +1,14 @@
1
1
  import { convertSchemaToJsonSchema } from './schema-converter'
2
- import type { Tool } from '../../../types'
2
+ import type { AnyTool, Tool } from '../../../types'
3
3
 
4
- const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
4
+ /**
5
+ * Name of the synthetic tool the LLM calls to discover lazy tools.
6
+ *
7
+ * Exported so callers building custom message-compaction / history-trimming
8
+ * logic can reference the discovery tool by constant instead of hard-coding
9
+ * the string (which is an internal contract that could change).
10
+ */
11
+ export const DISCOVERY_TOOL_NAME = '__lazy__tool__discovery__'
5
12
 
6
13
  /**
7
14
  * Manages lazy tool discovery for the chat agent loop.
@@ -87,6 +94,35 @@ export class LazyToolManager {
87
94
  return active
88
95
  }
89
96
 
97
+ /**
98
+ * Returns the tools that should be available for *execution* this turn.
99
+ *
100
+ * This is the advertised set (`getActiveTools()`, passed in as `activeTools`)
101
+ * plus the discovery tool when a pending call references it but it is no
102
+ * longer advertised. Once every lazy tool has been discovered the discovery
103
+ * tool is dropped from the advertised set, but a model may still re-request
104
+ * discovery (long context / hallucination); keeping it executable lets that
105
+ * call return the schemas again instead of failing with "Unknown tool".
106
+ *
107
+ * The advertised set is intentionally left unchanged — only execution lookup
108
+ * is widened. Operates on the already-built `activeTools`: it must NOT call
109
+ * `getActiveTools()`, which would reset `hasNewDiscoveries` before the
110
+ * post-execution refresh check in the agent loop.
111
+ */
112
+ getExecutableTools(
113
+ activeTools: ReadonlyArray<AnyTool>,
114
+ pendingToolCallNames: ReadonlyArray<string>,
115
+ ): ReadonlyArray<AnyTool> {
116
+ if (
117
+ this.discoveryTool &&
118
+ pendingToolCallNames.includes(DISCOVERY_TOOL_NAME) &&
119
+ !activeTools.some((t) => t.name === DISCOVERY_TOOL_NAME)
120
+ ) {
121
+ return [...activeTools, this.discoveryTool]
122
+ }
123
+ return activeTools
124
+ }
125
+
90
126
  /**
91
127
  * Returns whether new tools have been discovered since the last getActiveTools() call.
92
128
  */
@@ -221,8 +257,14 @@ export class LazyToolManager {
221
257
  for (const name of args.toolNames) {
222
258
  const tool = lazyToolMap.get(name)
223
259
  if (tool) {
224
- manager.discoveredTools.add(name)
225
- manager.hasNewDiscoveries = true
260
+ // Only flag a refresh for genuinely new discoveries. Re-requesting
261
+ // an already-discovered tool still returns its schema below (the
262
+ // model asked for it), but must not trigger a redundant tool-list
263
+ // refresh + continue in the agent loop.
264
+ if (!manager.discoveredTools.has(name)) {
265
+ manager.discoveredTools.add(name)
266
+ manager.hasNewDiscoveries = true
267
+ }
226
268
  const jsonSchema = tool.inputSchema
227
269
  ? convertSchemaToJsonSchema(tool.inputSchema)
228
270
  : undefined