@namzu/sdk 1.1.0 → 1.3.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 (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +4 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  9. package/dist/constants/continuation.d.ts.map +1 -0
  10. package/dist/{runtime/query → constants}/continuation.js +5 -0
  11. package/dist/constants/continuation.js.map +1 -0
  12. package/dist/constants/index.d.ts +1 -0
  13. package/dist/constants/index.d.ts.map +1 -1
  14. package/dist/constants/index.js +1 -0
  15. package/dist/constants/index.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +11 -0
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +30 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/registry.test.d.ts +5 -0
  21. package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
  22. package/dist/provider/__tests__/registry.test.js +186 -1
  23. package/dist/provider/__tests__/registry.test.js.map +1 -1
  24. package/dist/provider/capabilities.d.ts +27 -0
  25. package/dist/provider/capabilities.d.ts.map +1 -0
  26. package/dist/provider/capabilities.js +29 -0
  27. package/dist/provider/capabilities.js.map +1 -0
  28. package/dist/provider/index.d.ts +3 -1
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +2 -1
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/provider/registry.d.ts +81 -1
  33. package/dist/provider/registry.d.ts.map +1 -1
  34. package/dist/provider/registry.js +173 -7
  35. package/dist/provider/registry.js.map +1 -1
  36. package/dist/public-runtime.d.ts +3 -2
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +2 -2
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/run/reporter.d.ts.map +1 -1
  41. package/dist/run/reporter.js +8 -0
  42. package/dist/run/reporter.js.map +1 -1
  43. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  44. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  45. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  47. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  48. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  49. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  50. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  51. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  52. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  53. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  54. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  55. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  56. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  57. package/dist/runtime/query/checkpoint.d.ts +16 -2
  58. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  59. package/dist/runtime/query/checkpoint.js +21 -7
  60. package/dist/runtime/query/checkpoint.js.map +1 -1
  61. package/dist/runtime/query/context.d.ts +7 -0
  62. package/dist/runtime/query/context.d.ts.map +1 -1
  63. package/dist/runtime/query/context.js +1 -0
  64. package/dist/runtime/query/context.js.map +1 -1
  65. package/dist/runtime/query/index.d.ts +20 -0
  66. package/dist/runtime/query/index.d.ts.map +1 -1
  67. package/dist/runtime/query/index.js +75 -6
  68. package/dist/runtime/query/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/index.d.ts +3 -57
  70. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/index.js +246 -735
  72. package/dist/runtime/query/iteration/index.js.map +1 -1
  73. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  74. package/dist/runtime/query/iteration/phases/checkpoint.js +22 -0
  75. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  76. package/dist/runtime/query/iteration/phases/context.d.ts +1 -2
  77. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  78. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  79. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  80. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  81. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  82. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  83. package/dist/runtime/query/replay/list.d.ts +17 -1
  84. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  85. package/dist/runtime/query/replay/list.js +21 -4
  86. package/dist/runtime/query/replay/list.js.map +1 -1
  87. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  88. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  89. package/dist/runtime/query/replay/prepare.js +20 -4
  90. package/dist/runtime/query/replay/prepare.js.map +1 -1
  91. package/dist/store/index.d.ts +1 -0
  92. package/dist/store/index.d.ts.map +1 -1
  93. package/dist/store/index.js +1 -0
  94. package/dist/store/index.js.map +1 -1
  95. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  96. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  97. package/dist/store/run/checkpoint-disk.js +55 -0
  98. package/dist/store/run/checkpoint-disk.js.map +1 -0
  99. package/dist/types/provider/config.d.ts +51 -0
  100. package/dist/types/provider/config.d.ts.map +1 -1
  101. package/dist/types/provider/index.d.ts +1 -1
  102. package/dist/types/provider/index.d.ts.map +1 -1
  103. package/dist/types/provider/interface.d.ts +10 -0
  104. package/dist/types/provider/interface.d.ts.map +1 -1
  105. package/dist/types/run/checkpoint-store.d.ts +64 -0
  106. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  107. package/dist/types/run/checkpoint-store.js +12 -0
  108. package/dist/types/run/checkpoint-store.js.map +1 -0
  109. package/dist/types/run/config.d.ts +27 -0
  110. package/dist/types/run/config.d.ts.map +1 -1
  111. package/dist/types/run/events.d.ts +6 -0
  112. package/dist/types/run/events.d.ts.map +1 -1
  113. package/dist/types/run/events.js.map +1 -1
  114. package/dist/types/run/index.d.ts +1 -0
  115. package/dist/types/run/index.d.ts.map +1 -1
  116. package/dist/types/run/index.js +1 -0
  117. package/dist/types/run/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/bridge/a2a/mapper.ts +3 -0
  120. package/src/bridge/sse/mapper.ts +5 -0
  121. package/src/{runtime/query → constants}/continuation.ts +5 -0
  122. package/src/constants/index.ts +1 -0
  123. package/src/manager/run/persistence.ts +34 -1
  124. package/src/provider/__tests__/registry.test.ts +266 -1
  125. package/src/provider/capabilities.ts +47 -0
  126. package/src/provider/index.ts +12 -1
  127. package/src/provider/registry.ts +202 -7
  128. package/src/public-runtime.ts +6 -0
  129. package/src/run/reporter.ts +9 -0
  130. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  131. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  132. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  133. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  134. package/src/runtime/query/checkpoint.ts +23 -9
  135. package/src/runtime/query/context.ts +9 -0
  136. package/src/runtime/query/index.ts +115 -28
  137. package/src/runtime/query/iteration/index.ts +298 -905
  138. package/src/runtime/query/iteration/phases/checkpoint.ts +25 -0
  139. package/src/runtime/query/iteration/phases/context.ts +1 -3
  140. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  141. package/src/runtime/query/replay/list.ts +38 -4
  142. package/src/runtime/query/replay/prepare.ts +37 -4
  143. package/src/store/index.ts +1 -0
  144. package/src/store/run/checkpoint-disk.ts +67 -0
  145. package/src/types/provider/config.ts +54 -0
  146. package/src/types/provider/index.ts +3 -0
  147. package/src/types/provider/interface.ts +11 -0
  148. package/src/types/run/checkpoint-store.ts +72 -0
  149. package/src/types/run/config.ts +30 -0
  150. package/src/types/run/events.ts +13 -0
  151. package/src/types/run/index.ts +1 -0
  152. package/dist/runtime/query/continuation.d.ts.map +0 -1
  153. package/dist/runtime/query/continuation.js.map +0 -1
@@ -1,13 +1,17 @@
1
1
  import type {
2
2
  LLMProvider,
3
3
  LLMProviderConstructor,
4
+ LazyProviderLoader,
5
+ LazyProviderModule,
4
6
  ProviderCapabilities,
5
7
  ProviderConfigRegistry,
6
8
  ProviderFactoryConfig,
7
9
  ProviderFactoryResult,
8
10
  ProviderType,
11
+ RegisterLazyOptions,
9
12
  RegisterOptions,
10
13
  } from '../types/provider/index.js'
14
+ import { PERMISSIVE_PROVIDER_CAPABILITIES } from './capabilities.js'
11
15
 
12
16
  export class UnknownProviderError extends Error {
13
17
  readonly providerType: string
@@ -31,9 +35,99 @@ export class DuplicateProviderError extends Error {
31
35
  }
32
36
  }
33
37
 
38
+ /**
39
+ * The loader passed to `ProviderRegistry.registerLazy()` rejected (or
40
+ * resolved to something without a `create(config)` function). Wraps the
41
+ * original failure as `cause`. The failed load is NOT cached — the next
42
+ * `createAsync()` for the type re-invokes the loader, so a transient
43
+ * failure (network hiccup during a dynamic import) does not permanently
44
+ * poison the type.
45
+ */
46
+ export class LazyProviderLoadError extends Error {
47
+ readonly providerType: string
48
+
49
+ constructor(providerType: string, cause: unknown) {
50
+ const detail = cause instanceof Error ? cause.message : String(cause)
51
+ super(`Failed to load lazy provider "${providerType}": ${detail}`, { cause })
52
+ this.name = 'LazyProviderLoadError'
53
+ this.providerType = providerType
54
+ }
55
+ }
56
+
57
+ /**
58
+ * A synchronous `create()`/`createProvider()` was called for a type
59
+ * registered via `registerLazy()`. Lazy types are only constructible
60
+ * through the async path — deterministically, even after the loader has
61
+ * resolved, so calling code never depends on load-order timing.
62
+ */
63
+ export class LazyProviderSyncCreateError extends Error {
64
+ readonly providerType: string
65
+
66
+ constructor(providerType: string) {
67
+ super(
68
+ `Provider type "${providerType}" is registered lazily; synchronous create()/createProvider() cannot load it. Use ProviderRegistry.createAsync() or createProviderAsync().`,
69
+ )
70
+ this.name = 'LazyProviderSyncCreateError'
71
+ this.providerType = providerType
72
+ }
73
+ }
74
+
75
+ interface LazyProviderEntry {
76
+ loader: LazyProviderLoader<unknown>
77
+ /** In-flight load shared by concurrent first-creates (dedupe). */
78
+ loading?: Promise<LazyProviderModule<unknown>>
79
+ /** Cached module — set only on SUCCESS, so failures retry. */
80
+ module?: LazyProviderModule<unknown>
81
+ }
82
+
34
83
  // Module-private state. Only the exported functions below can read/mutate.
35
84
  const providers = new Map<string, LLMProviderConstructor<unknown>>()
36
85
  const capabilities = new Map<string, ProviderCapabilities>()
86
+ const lazyProviders = new Map<string, LazyProviderEntry>()
87
+
88
+ async function loadLazyModule(
89
+ type: string,
90
+ entry: LazyProviderEntry,
91
+ ): Promise<LazyProviderModule<unknown>> {
92
+ if (entry.module) {
93
+ return entry.module
94
+ }
95
+ let inflight = entry.loading
96
+ if (!inflight) {
97
+ // Promise.resolve().then(...) also catches loaders that throw synchronously.
98
+ inflight = Promise.resolve()
99
+ .then(() => entry.loader())
100
+ .then((mod) => {
101
+ if (typeof mod?.create !== 'function') {
102
+ throw new TypeError(
103
+ 'loader resolved to a value without a create(config) function — map your dynamic import to { create: (config) => new Provider(config) }',
104
+ )
105
+ }
106
+ return mod
107
+ })
108
+ entry.loading = inflight
109
+ }
110
+ try {
111
+ const mod = await inflight
112
+ entry.module = mod
113
+ if (entry.loading === inflight) {
114
+ entry.loading = undefined
115
+ }
116
+ // The loaded module's declaration is authoritative at the type level:
117
+ // it replaces any registration-time hint for future getCapabilities().
118
+ if (mod.capabilities) {
119
+ capabilities.set(type, mod.capabilities)
120
+ }
121
+ return mod
122
+ } catch (cause) {
123
+ // Clear only OUR in-flight promise; a retry started by another caller
124
+ // after an earlier failure must not be dropped.
125
+ if (entry.loading === inflight) {
126
+ entry.loading = undefined
127
+ }
128
+ throw new LazyProviderLoadError(type, cause)
129
+ }
130
+ }
37
131
 
38
132
  /**
39
133
  * Central registry for LLM providers.
@@ -56,6 +150,23 @@ const capabilities = new Map<string, ProviderCapabilities>()
56
150
  * region: 'us-east-1',
57
151
  * })
58
152
  * ```
153
+ *
154
+ * Hosts that must not eagerly bundle every provider client register a
155
+ * LOADER instead and construct through the async path:
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * ProviderRegistry.registerLazy(
160
+ * 'anthropic',
161
+ * async () => {
162
+ * const m = await import('@namzu/anthropic')
163
+ * return { create: (c) => new m.AnthropicProvider(c), capabilities: m.ANTHROPIC_CAPABILITIES }
164
+ * },
165
+ * { capabilities: { supportsTools: true, supportsStreaming: true, supportsFunctionCalling: true } },
166
+ * )
167
+ *
168
+ * const { provider } = await ProviderRegistry.createAsync({ type: 'anthropic', apiKey })
169
+ * ```
59
170
  */
60
171
  export class ProviderRegistry {
61
172
  static register<K extends ProviderType>(
@@ -64,46 +175,129 @@ export class ProviderRegistry {
64
175
  caps: ProviderCapabilities,
65
176
  options?: RegisterOptions,
66
177
  ): void {
67
- if (providers.has(type) && !options?.replace) {
178
+ if ((providers.has(type) || lazyProviders.has(type)) && !options?.replace) {
68
179
  throw new DuplicateProviderError(type)
69
180
  }
181
+ lazyProviders.delete(type)
70
182
  providers.set(type, ctor as LLMProviderConstructor<unknown>)
71
183
  capabilities.set(type, caps)
72
184
  }
73
185
 
186
+ /**
187
+ * Register a provider type WITHOUT importing its implementation. The
188
+ * loader is not invoked here; the first `createAsync()` for the type
189
+ * awaits it, validates the resolved `{ create }` module, and caches it.
190
+ * Subsequent creates reuse the cached factory. Only SUCCESS is cached:
191
+ * a rejected load surfaces as `LazyProviderLoadError` and the next
192
+ * `createAsync()` retries the loader. Concurrent first-creates share a
193
+ * single in-flight load.
194
+ *
195
+ * Capability precedence (weakest first):
196
+ * 1. `options.capabilities` — pre-load HINT so `getCapabilities(type)`
197
+ * answers without loading (absent hint ⇒ permissive default, matching
198
+ * `resolveProviderCapabilities`'s treatment of undeclared providers).
199
+ * 2. the loaded module's `capabilities` — replaces the hint on load.
200
+ * 3. the constructed instance's own `LLMProvider.capabilities` — the
201
+ * query runtime negotiates against the INSTANCE
202
+ * (`resolveProviderCapabilities(provider)`), so if it differs from
203
+ * both of the above, the instance wins where it matters.
204
+ *
205
+ * Lazy types are deliberately NOT constructible via the sync
206
+ * `create()`/`createProvider()` (throws `LazyProviderSyncCreateError`),
207
+ * even after the loader has resolved — sync behavior must not depend on
208
+ * whether some earlier call happened to load the module.
209
+ */
210
+ static registerLazy<K extends ProviderType>(
211
+ type: K,
212
+ loader: LazyProviderLoader<ProviderConfigRegistry[K]>,
213
+ options?: RegisterLazyOptions,
214
+ ): void {
215
+ if ((providers.has(type) || lazyProviders.has(type)) && !options?.replace) {
216
+ throw new DuplicateProviderError(type)
217
+ }
218
+ providers.delete(type)
219
+ capabilities.delete(type)
220
+ lazyProviders.set(type, { loader: loader as LazyProviderLoader<unknown> })
221
+ if (options?.capabilities) {
222
+ capabilities.set(type, options.capabilities)
223
+ }
224
+ }
225
+
74
226
  static create(config: ProviderFactoryConfig): ProviderFactoryResult {
75
227
  const provider = ProviderRegistry.createProvider(config)
76
228
  const caps = ProviderRegistry.getCapabilities(config.type)
77
229
  return { provider, capabilities: caps }
78
230
  }
79
231
 
232
+ /**
233
+ * Async twin of `create()`. Works for BOTH eager and lazy registrations,
234
+ * so hosts can use one code path; for lazy types it performs the
235
+ * load-on-first-use described on `registerLazy()`.
236
+ */
237
+ static async createAsync(config: ProviderFactoryConfig): Promise<ProviderFactoryResult> {
238
+ const provider = await ProviderRegistry.createProviderAsync(config)
239
+ // Read capabilities AFTER construction so a lazily-loaded module's
240
+ // authoritative declaration (set during load) is what gets returned.
241
+ const caps = ProviderRegistry.getCapabilities(config.type)
242
+ return { provider, capabilities: caps }
243
+ }
244
+
80
245
  static createProvider(config: ProviderFactoryConfig): LLMProvider {
81
246
  const Ctor = providers.get(config.type)
82
247
  if (!Ctor) {
248
+ if (lazyProviders.has(config.type)) {
249
+ throw new LazyProviderSyncCreateError(config.type)
250
+ }
83
251
  throw new UnknownProviderError(config.type)
84
252
  }
85
253
  return new Ctor(config)
86
254
  }
87
255
 
256
+ static async createProviderAsync(config: ProviderFactoryConfig): Promise<LLMProvider> {
257
+ const Ctor = providers.get(config.type)
258
+ if (Ctor) {
259
+ return new Ctor(config)
260
+ }
261
+ const entry = lazyProviders.get(config.type)
262
+ if (!entry) {
263
+ throw new UnknownProviderError(config.type)
264
+ }
265
+ const mod = await loadLazyModule(config.type, entry)
266
+ return mod.create(config)
267
+ }
268
+
269
+ /**
270
+ * Type-level capabilities. For a lazily-registered type this answers
271
+ * WITHOUT invoking the loader: the registration hint if one was given,
272
+ * otherwise the permissive default (assume everything — consistent with
273
+ * how `resolveProviderCapabilities` treats an undeclared provider). Once
274
+ * loaded, a module-shipped declaration replaces the hint. Note the query
275
+ * runtime negotiates against the constructed INSTANCE's own
276
+ * `capabilities`, which wins over anything stored here.
277
+ */
88
278
  static getCapabilities(type: string): ProviderCapabilities {
89
279
  const caps = capabilities.get(type)
90
- if (!caps) {
91
- throw new UnknownProviderError(type)
280
+ if (caps) {
281
+ return caps
282
+ }
283
+ if (lazyProviders.has(type)) {
284
+ return PERMISSIVE_PROVIDER_CAPABILITIES
92
285
  }
93
- return caps
286
+ throw new UnknownProviderError(type)
94
287
  }
95
288
 
96
289
  static isSupported(type: string): type is ProviderType {
97
- return providers.has(type)
290
+ return providers.has(type) || lazyProviders.has(type)
98
291
  }
99
292
 
100
293
  static unregister(type: ProviderType): boolean {
101
294
  capabilities.delete(type)
102
- return providers.delete(type)
295
+ const hadLazy = lazyProviders.delete(type)
296
+ return providers.delete(type) || hadLazy
103
297
  }
104
298
 
105
299
  static listTypes(): ProviderType[] {
106
- return Array.from(providers.keys()) as ProviderType[]
300
+ return Array.from(new Set([...providers.keys(), ...lazyProviders.keys()])) as ProviderType[]
107
301
  }
108
302
  }
109
303
 
@@ -115,4 +309,5 @@ export class ProviderRegistry {
115
309
  export function __resetProviderRegistryInternal(): void {
116
310
  providers.clear()
117
311
  capabilities.clear()
312
+ lazyProviders.clear()
118
313
  }
@@ -99,6 +99,7 @@ export {
99
99
 
100
100
  export {
101
101
  ActivityStore,
102
+ DiskCheckpointStore,
102
103
  DiskMemoryStore,
103
104
  DiskTaskStore,
104
105
  InMemoryMemoryIndex,
@@ -144,10 +145,14 @@ export { LocalTaskGateway } from './gateway/local.js'
144
145
 
145
146
  export {
146
147
  DuplicateProviderError,
148
+ LazyProviderLoadError,
149
+ LazyProviderSyncCreateError,
147
150
  MOCK_CAPABILITIES,
148
151
  MockLLMProvider,
152
+ PERMISSIVE_PROVIDER_CAPABILITIES,
149
153
  ProviderRegistry,
150
154
  registerMock,
155
+ resolveProviderCapabilities,
151
156
  UnknownProviderError,
152
157
  } from './provider/index.js'
153
158
 
@@ -248,6 +253,7 @@ export {
248
253
 
249
254
  export { wrapProviderWithProbes } from './provider/instrumentation.js'
250
255
  export type { ProviderInstrumentationOptions } from './provider/instrumentation.js'
256
+ export type { ResolvedProviderCapabilities } from './provider/capabilities.js'
251
257
  export { collect } from './provider/collect.js'
252
258
 
253
259
  export { wrapVaultWithProbes } from './vault/instrumentation.js'
@@ -225,6 +225,15 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
225
225
  })
226
226
  break
227
227
 
228
+ case 'capability_warning':
229
+ log.warn('Provider capability mismatch', {
230
+ runId: event.runId,
231
+ capability: event.capability,
232
+ providerId: event.providerId,
233
+ message: event.message,
234
+ })
235
+ break
236
+
228
237
  default: {
229
238
  const _exhaustive: never = event
230
239
  throw new Error(`Unhandled run event type: ${(_exhaustive as RunEvent).type}`)
@@ -0,0 +1,248 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ PERMISSIVE_PROVIDER_CAPABILITIES,
9
+ resolveProviderCapabilities,
10
+ } from '../../../provider/capabilities.js'
11
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
12
+ import type { SessionId, TenantId } from '../../../types/ids/index.js'
13
+ import { createUserMessage } from '../../../types/message/index.js'
14
+ import type {
15
+ ChatCompletionParams,
16
+ LLMProvider,
17
+ ProviderCapabilities,
18
+ StreamChunk,
19
+ } from '../../../types/provider/index.js'
20
+ import type { RunEvent } from '../../../types/run/index.js'
21
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
22
+ import { drainQuery } from '../index.js'
23
+
24
+ const ZERO_USAGE = {
25
+ promptTokens: 0,
26
+ completionTokens: 0,
27
+ totalTokens: 0,
28
+ cachedTokens: 0,
29
+ cacheWriteTokens: 0,
30
+ }
31
+
32
+ class CapturingProvider implements LLMProvider {
33
+ readonly id = 'capturing'
34
+ readonly name = 'Capturing Provider'
35
+ readonly capabilities?: ProviderCapabilities
36
+ lastParams?: ChatCompletionParams
37
+
38
+ constructor(capabilities?: ProviderCapabilities) {
39
+ this.capabilities = capabilities
40
+ }
41
+
42
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
43
+ this.lastParams = params
44
+ yield { id: 'msg_1', delta: { content: 'done' } }
45
+ yield { id: 'msg_1', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
46
+ }
47
+ }
48
+
49
+ const NO_TOOLS_CAPABILITIES: ProviderCapabilities = {
50
+ supportsTools: false,
51
+ supportsStreaming: true,
52
+ supportsFunctionCalling: false,
53
+ supportsVision: true,
54
+ }
55
+
56
+ const NO_VISION_CAPABILITIES: ProviderCapabilities = {
57
+ supportsTools: true,
58
+ supportsStreaming: true,
59
+ supportsFunctionCalling: true,
60
+ supportsVision: false,
61
+ }
62
+
63
+ function registerEchoTool(tools: ToolRegistry): void {
64
+ tools.register({
65
+ name: 'echo',
66
+ description: 'Echo the text back.',
67
+ inputSchema: z.object({ text: z.string() }),
68
+ execute: async () => ({ success: true, output: 'ok' }),
69
+ })
70
+ }
71
+
72
+ function baseParams(provider: LLMProvider, tools: ToolRegistry, workingDirectory: string) {
73
+ return {
74
+ provider,
75
+ tools,
76
+ runConfig: {
77
+ model: 'mock-model',
78
+ timeoutMs: 5_000,
79
+ tokenBudget: 100_000,
80
+ maxIterations: 1,
81
+ maxResponseTokens: 256,
82
+ },
83
+ agentId: 'agent_test',
84
+ agentName: 'Test Agent',
85
+ workingDirectory,
86
+ sessionId: 'ses_capability' as SessionId,
87
+ threadId: 'thd_capability' as ThreadId,
88
+ projectId: 'prj_capability' as ProjectId,
89
+ tenantId: 'tnt_capability' as TenantId,
90
+ }
91
+ }
92
+
93
+ describe('resolveProviderCapabilities', () => {
94
+ it('resolves an undeclared provider to the permissive default', () => {
95
+ expect(resolveProviderCapabilities(new CapturingProvider())).toEqual(
96
+ PERMISSIVE_PROVIDER_CAPABILITIES,
97
+ )
98
+ })
99
+
100
+ it('fills a partial declaration (missing supportsVision) permissively per field', () => {
101
+ const resolved = resolveProviderCapabilities({
102
+ capabilities: {
103
+ supportsTools: false,
104
+ supportsStreaming: true,
105
+ supportsFunctionCalling: false,
106
+ },
107
+ })
108
+ expect(resolved.supportsTools).toBe(false)
109
+ expect(resolved.supportsVision).toBe(true)
110
+ })
111
+ })
112
+
113
+ describe('query() capability negotiation', () => {
114
+ let workdirs: string[] = []
115
+
116
+ afterEach(async () => {
117
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
118
+ workdirs = []
119
+ })
120
+
121
+ async function mkWorkdir(): Promise<string> {
122
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-capability-'))
123
+ workdirs.push(dir)
124
+ return dir
125
+ }
126
+
127
+ it('strips tool surfaces and emits a capability_warning for a no-tools provider', async () => {
128
+ const provider = new CapturingProvider(NO_TOOLS_CAPABILITIES)
129
+ const tools = new ToolRegistry()
130
+ registerEchoTool(tools)
131
+ const events: RunEvent[] = []
132
+
133
+ const run = await drainQuery(
134
+ {
135
+ ...baseParams(provider, tools, await mkWorkdir()),
136
+ messages: [createUserMessage('hello')],
137
+ },
138
+ (event) => {
139
+ events.push(event)
140
+ },
141
+ )
142
+
143
+ expect(run.status).toBe('completed')
144
+
145
+ // The model was never told about tools: no tools param on the wire…
146
+ expect(provider.lastParams?.tools).toBeUndefined()
147
+ // …and no tool section in the system prompt.
148
+ const systemPrompt = (provider.lastParams?.messages ?? [])
149
+ .filter((m) => m.role === 'system')
150
+ .map((m) => m.content)
151
+ .join('\n')
152
+ expect(systemPrompt).not.toContain('<available_tools>')
153
+ expect(systemPrompt).not.toContain('echo')
154
+
155
+ // The host got the machine-readable warning.
156
+ const warning = events.find(
157
+ (e): e is Extract<RunEvent, { type: 'capability_warning' }> =>
158
+ e.type === 'capability_warning',
159
+ )
160
+ expect(warning?.capability).toBe('tools')
161
+ expect(warning?.providerId).toBe('capturing')
162
+ expect(warning?.message).toContain('stripped')
163
+ })
164
+
165
+ it('keeps tool surfaces intact for an undeclared (permissive-default) provider', async () => {
166
+ const provider = new CapturingProvider()
167
+ const tools = new ToolRegistry()
168
+ registerEchoTool(tools)
169
+
170
+ const run = await drainQuery({
171
+ ...baseParams(provider, tools, await mkWorkdir()),
172
+ messages: [createUserMessage('hello')],
173
+ })
174
+
175
+ expect(run.status).toBe('completed')
176
+ expect(provider.lastParams?.tools?.map((t) => t.function.name)).toContain('echo')
177
+ })
178
+
179
+ it('emits a vision capability_warning when attachments hit a no-vision provider', async () => {
180
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
181
+ const events: RunEvent[] = []
182
+
183
+ const run = await drainQuery(
184
+ {
185
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
186
+ messages: [
187
+ createUserMessage('what is in this image?', [
188
+ { data: 'aGVsbG8=', mediaType: 'image/png' },
189
+ ]),
190
+ ],
191
+ },
192
+ (event) => {
193
+ events.push(event)
194
+ },
195
+ )
196
+
197
+ expect(run.status).toBe('completed')
198
+ const warning = events.find(
199
+ (e): e is Extract<RunEvent, { type: 'capability_warning' }> =>
200
+ e.type === 'capability_warning',
201
+ )
202
+ expect(warning?.capability).toBe('vision')
203
+ expect(warning?.providerId).toBe('capturing')
204
+ })
205
+
206
+ it('does not warn when no attachments are present on a no-vision provider', async () => {
207
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
208
+ const events: RunEvent[] = []
209
+
210
+ await drainQuery(
211
+ {
212
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
213
+ messages: [createUserMessage('plain text')],
214
+ },
215
+ (event) => {
216
+ events.push(event)
217
+ },
218
+ )
219
+
220
+ expect(events.some((e) => e.type === 'capability_warning')).toBe(false)
221
+ })
222
+
223
+ it('strictCapabilities: true throws on a tools mismatch instead of degrading', async () => {
224
+ const provider = new CapturingProvider(NO_TOOLS_CAPABILITIES)
225
+ const tools = new ToolRegistry()
226
+ registerEchoTool(tools)
227
+
228
+ await expect(
229
+ drainQuery({
230
+ ...baseParams(provider, tools, await mkWorkdir()),
231
+ messages: [createUserMessage('hello')],
232
+ strictCapabilities: true,
233
+ }),
234
+ ).rejects.toThrow(/supportsTools: false/)
235
+ })
236
+
237
+ it('strictCapabilities: true throws on a vision mismatch instead of degrading', async () => {
238
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
239
+
240
+ await expect(
241
+ drainQuery({
242
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
243
+ messages: [createUserMessage('describe', [{ data: 'aGVsbG8=', mediaType: 'image/png' }])],
244
+ strictCapabilities: true,
245
+ }),
246
+ ).rejects.toThrow(/supportsVision: false/)
247
+ })
248
+ })