@namzu/sdk 0.4.3 → 0.4.5

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 (108) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/bridge/tools/connector/adapter.d.ts +2 -2
  3. package/dist/bus/index.d.ts +3 -1
  4. package/dist/bus/index.d.ts.map +1 -1
  5. package/dist/bus/index.js +18 -11
  6. package/dist/bus/index.js.map +1 -1
  7. package/dist/config/runtime.d.ts +28 -28
  8. package/dist/probe/context.d.ts +8 -0
  9. package/dist/probe/context.d.ts.map +1 -0
  10. package/dist/probe/context.js +7 -0
  11. package/dist/probe/context.js.map +1 -0
  12. package/dist/probe/errors.d.ts +12 -0
  13. package/dist/probe/errors.d.ts.map +1 -0
  14. package/dist/probe/errors.js +21 -0
  15. package/dist/probe/errors.js.map +1 -0
  16. package/dist/probe/index.d.ts +5 -0
  17. package/dist/probe/index.d.ts.map +1 -0
  18. package/dist/probe/index.js +4 -0
  19. package/dist/probe/index.js.map +1 -0
  20. package/dist/probe/registry.d.ts +24 -0
  21. package/dist/probe/registry.d.ts.map +1 -0
  22. package/dist/probe/registry.js +228 -0
  23. package/dist/probe/registry.js.map +1 -0
  24. package/dist/probe/registry.test.d.ts +7 -0
  25. package/dist/probe/registry.test.d.ts.map +1 -0
  26. package/dist/probe/registry.test.js +310 -0
  27. package/dist/probe/registry.test.js.map +1 -0
  28. package/dist/provider/instrumentation.d.ts +9 -0
  29. package/dist/provider/instrumentation.d.ts.map +1 -0
  30. package/dist/provider/instrumentation.js +104 -0
  31. package/dist/provider/instrumentation.js.map +1 -0
  32. package/dist/provider/instrumentation.test.d.ts +2 -0
  33. package/dist/provider/instrumentation.test.d.ts.map +1 -0
  34. package/dist/provider/instrumentation.test.js +152 -0
  35. package/dist/provider/instrumentation.test.js.map +1 -0
  36. package/dist/public-runtime.d.ts +5 -0
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +8 -0
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/public-types.d.ts +3 -0
  41. package/dist/public-types.d.ts.map +1 -1
  42. package/dist/runtime/query/events.d.ts +3 -1
  43. package/dist/runtime/query/events.d.ts.map +1 -1
  44. package/dist/runtime/query/events.js +6 -1
  45. package/dist/runtime/query/events.js.map +1 -1
  46. package/dist/runtime/query/executor.d.ts +3 -1
  47. package/dist/runtime/query/executor.d.ts.map +1 -1
  48. package/dist/runtime/query/executor.js +30 -1
  49. package/dist/runtime/query/executor.js.map +1 -1
  50. package/dist/types/bus/index.d.ts +46 -2
  51. package/dist/types/bus/index.d.ts.map +1 -1
  52. package/dist/types/doctor/check.d.ts +41 -0
  53. package/dist/types/doctor/check.d.ts.map +1 -0
  54. package/dist/types/doctor/check.js +2 -0
  55. package/dist/types/doctor/check.js.map +1 -0
  56. package/dist/types/doctor/index.d.ts +2 -0
  57. package/dist/types/doctor/index.d.ts.map +1 -0
  58. package/dist/types/doctor/index.js +2 -0
  59. package/dist/types/doctor/index.js.map +1 -0
  60. package/dist/types/probe/event-kind.d.ts +6 -0
  61. package/dist/types/probe/event-kind.d.ts.map +1 -0
  62. package/dist/types/probe/event-kind.js +2 -0
  63. package/dist/types/probe/event-kind.js.map +1 -0
  64. package/dist/types/probe/event-of.d.ts +5 -0
  65. package/dist/types/probe/event-of.d.ts.map +1 -0
  66. package/dist/types/probe/event-of.js +2 -0
  67. package/dist/types/probe/event-of.js.map +1 -0
  68. package/dist/types/probe/index.d.ts +4 -0
  69. package/dist/types/probe/index.d.ts.map +1 -0
  70. package/dist/types/probe/index.js +2 -0
  71. package/dist/types/probe/index.js.map +1 -0
  72. package/dist/types/probe/registry.d.ts +27 -0
  73. package/dist/types/probe/registry.d.ts.map +1 -0
  74. package/dist/types/probe/registry.js +2 -0
  75. package/dist/types/probe/registry.js.map +1 -0
  76. package/dist/types/provider/interface.d.ts +10 -0
  77. package/dist/types/provider/interface.d.ts.map +1 -1
  78. package/dist/vault/instrumentation.d.ts +11 -0
  79. package/dist/vault/instrumentation.d.ts.map +1 -0
  80. package/dist/vault/instrumentation.js +32 -0
  81. package/dist/vault/instrumentation.js.map +1 -0
  82. package/dist/vault/instrumentation.test.d.ts +2 -0
  83. package/dist/vault/instrumentation.test.d.ts.map +1 -0
  84. package/dist/vault/instrumentation.test.js +80 -0
  85. package/dist/vault/instrumentation.test.js.map +1 -0
  86. package/package.json +1 -1
  87. package/src/bus/index.ts +21 -10
  88. package/src/probe/context.ts +14 -0
  89. package/src/probe/errors.ts +27 -0
  90. package/src/probe/index.ts +4 -0
  91. package/src/probe/registry.test.ts +480 -0
  92. package/src/probe/registry.ts +276 -0
  93. package/src/provider/instrumentation.test.ts +192 -0
  94. package/src/provider/instrumentation.ts +139 -0
  95. package/src/public-runtime.ts +22 -0
  96. package/src/public-types.ts +3 -0
  97. package/src/runtime/query/events.ts +6 -1
  98. package/src/runtime/query/executor.ts +34 -0
  99. package/src/types/bus/index.ts +54 -2
  100. package/src/types/doctor/check.ts +53 -0
  101. package/src/types/doctor/index.ts +9 -0
  102. package/src/types/probe/event-kind.ts +8 -0
  103. package/src/types/probe/event-of.ts +3 -0
  104. package/src/types/probe/index.ts +11 -0
  105. package/src/types/probe/registry.ts +36 -0
  106. package/src/types/provider/interface.ts +12 -0
  107. package/src/vault/instrumentation.test.ts +98 -0
  108. package/src/vault/instrumentation.ts +56 -0
@@ -0,0 +1,276 @@
1
+ import type {
2
+ ProbeContext,
3
+ ProbeEvent,
4
+ ProbeEventKind,
5
+ ProbeEventOf,
6
+ ProbeHandler,
7
+ ProbeOptions,
8
+ Unsubscribe,
9
+ VetoDecision,
10
+ VetoHandler,
11
+ VetoOutcome,
12
+ VetoableEventKind,
13
+ } from '../types/probe/index.js'
14
+ import type { Logger } from '../utils/logger.js'
15
+
16
+ import { ProbeNameCollisionError } from './errors.js'
17
+
18
+ interface ProbeEntry {
19
+ readonly id: number
20
+ readonly name?: string
21
+ readonly priority: number
22
+ readonly handler: (event: ProbeEvent, ctx: ProbeContext) => void
23
+ readonly where?: (event: ProbeEvent) => boolean
24
+ }
25
+
26
+ interface VetoEntry {
27
+ readonly id: number
28
+ readonly name?: string
29
+ readonly priority: number
30
+ readonly handler: (event: ProbeEvent, ctx: ProbeContext) => VetoDecision
31
+ readonly where?: (event: ProbeEvent) => boolean
32
+ }
33
+
34
+ function compareVeto(a: VetoEntry, b: VetoEntry): number {
35
+ if (a.priority !== b.priority) return a.priority - b.priority
36
+ return a.id - b.id
37
+ }
38
+
39
+ function insertSortedVeto(list: VetoEntry[], entry: VetoEntry): void {
40
+ let lo = 0
41
+ let hi = list.length
42
+ while (lo < hi) {
43
+ const mid = (lo + hi) >>> 1
44
+ if (compareVeto(list[mid] as VetoEntry, entry) <= 0) lo = mid + 1
45
+ else hi = mid
46
+ }
47
+ list.splice(lo, 0, entry)
48
+ }
49
+
50
+ function removeVeto(list: VetoEntry[], entry: VetoEntry): void {
51
+ const idx = list.indexOf(entry)
52
+ if (idx >= 0) list.splice(idx, 1)
53
+ }
54
+
55
+ function normalizeDecision(decision: VetoDecision): { allow: boolean; reason?: string } {
56
+ if (decision === 'allow') return { allow: true }
57
+ if (decision === 'deny') return { allow: false }
58
+ return { allow: false, reason: decision.reason }
59
+ }
60
+
61
+ function compareEntries(a: ProbeEntry, b: ProbeEntry): number {
62
+ if (a.priority !== b.priority) return a.priority - b.priority
63
+ return a.id - b.id
64
+ }
65
+
66
+ function insertSorted(list: ProbeEntry[], entry: ProbeEntry): void {
67
+ let lo = 0
68
+ let hi = list.length
69
+ while (lo < hi) {
70
+ const mid = (lo + hi) >>> 1
71
+ if (compareEntries(list[mid] as ProbeEntry, entry) <= 0) lo = mid + 1
72
+ else hi = mid
73
+ }
74
+ list.splice(lo, 0, entry)
75
+ }
76
+
77
+ function removeEntry(list: ProbeEntry[], entry: ProbeEntry): void {
78
+ const idx = list.indexOf(entry)
79
+ if (idx >= 0) list.splice(idx, 1)
80
+ }
81
+
82
+ export class ProbeRegistry {
83
+ private readonly typedByKind: Map<ProbeEventKind, ProbeEntry[]> = new Map()
84
+ private readonly vetoByKind: Map<VetoableEventKind, VetoEntry[]> = new Map()
85
+ private readonly catchAll: ProbeEntry[] = []
86
+ private readonly byName: Map<string, ProbeEntry | VetoEntry> = new Map()
87
+ private nextId = 1
88
+ private log?: Logger
89
+
90
+ setLogger(log: Logger): void {
91
+ this.log = log.child({ component: 'ProbeRegistry' })
92
+ }
93
+
94
+ on<K extends ProbeEventKind>(
95
+ kind: K | readonly K[],
96
+ handler: ProbeHandler<K>,
97
+ opts: ProbeOptions<K> = {},
98
+ ): Unsubscribe {
99
+ const kinds: readonly K[] = Array.isArray(kind) ? kind : [kind as K]
100
+ const entry = this.makeEntry(
101
+ handler as (e: ProbeEvent, c: ProbeContext) => void,
102
+ opts as unknown as ProbeOptions,
103
+ )
104
+
105
+ for (const k of kinds) {
106
+ let bucket = this.typedByKind.get(k)
107
+ if (!bucket) {
108
+ bucket = []
109
+ this.typedByKind.set(k, bucket)
110
+ }
111
+ insertSorted(bucket, entry)
112
+ }
113
+
114
+ return () => {
115
+ for (const k of kinds) {
116
+ const bucket = this.typedByKind.get(k)
117
+ if (bucket) removeEntry(bucket, entry)
118
+ }
119
+ if (entry.name) this.byName.delete(entry.name)
120
+ }
121
+ }
122
+
123
+ onAny(
124
+ handler: (event: ProbeEvent, ctx: ProbeContext) => void,
125
+ opts: ProbeOptions = {},
126
+ ): Unsubscribe {
127
+ const entry = this.makeEntry(handler, opts)
128
+ insertSorted(this.catchAll, entry)
129
+ return () => {
130
+ removeEntry(this.catchAll, entry)
131
+ if (entry.name) this.byName.delete(entry.name)
132
+ }
133
+ }
134
+
135
+ dispatch(event: ProbeEvent, ctx: ProbeContext, betweenTier?: () => void): void {
136
+ const frozen = Object.isFrozen(event) ? event : Object.freeze(event)
137
+ this.runTier(this.typedByKind.get(frozen.type) ?? [], frozen, ctx)
138
+ if (betweenTier) {
139
+ try {
140
+ betweenTier()
141
+ } catch (error) {
142
+ this.logThrow('between-tier', frozen.type, error)
143
+ }
144
+ }
145
+ this.runTier(this.catchAll, frozen, ctx)
146
+ }
147
+
148
+ veto<K extends VetoableEventKind>(
149
+ kind: K,
150
+ handler: VetoHandler<K>,
151
+ opts: ProbeOptions<K> = {},
152
+ ): Unsubscribe {
153
+ if (opts.name !== undefined) {
154
+ const existing = this.byName.get(opts.name)
155
+ if (existing && !opts.override) {
156
+ throw new ProbeNameCollisionError(opts.name)
157
+ }
158
+ if (existing && opts.override) this.removeAnywhere(existing)
159
+ }
160
+
161
+ const entry: VetoEntry = {
162
+ id: this.nextId++,
163
+ name: opts.name,
164
+ priority: opts.priority ?? 0,
165
+ handler: handler as (e: ProbeEvent, c: ProbeContext) => VetoDecision,
166
+ where: opts.where as ((event: ProbeEvent) => boolean) | undefined,
167
+ }
168
+
169
+ let bucket = this.vetoByKind.get(kind)
170
+ if (!bucket) {
171
+ bucket = []
172
+ this.vetoByKind.set(kind, bucket)
173
+ }
174
+ insertSortedVeto(bucket, entry)
175
+ if (entry.name) this.byName.set(entry.name, entry)
176
+
177
+ return () => {
178
+ const list = this.vetoByKind.get(kind)
179
+ if (list) removeVeto(list, entry)
180
+ if (entry.name) this.byName.delete(entry.name)
181
+ }
182
+ }
183
+
184
+ queryVeto<K extends VetoableEventKind>(event: ProbeEventOf<K>, ctx: ProbeContext): VetoOutcome {
185
+ const wide = event as unknown as ProbeEvent
186
+ const frozen = Object.isFrozen(wide) ? wide : Object.freeze(wide)
187
+ const bucket = this.vetoByKind.get(frozen.type as VetoableEventKind)
188
+ if (!bucket || bucket.length === 0) return { action: 'allow' }
189
+
190
+ let firstDeny: VetoOutcome | undefined
191
+ for (const entry of bucket) {
192
+ if (entry.where && !entry.where(frozen)) continue
193
+ let decision: VetoDecision
194
+ try {
195
+ decision = entry.handler(frozen, ctx)
196
+ } catch (error) {
197
+ this.logThrow(entry.name ?? 'unnamed', frozen.type, error)
198
+ continue
199
+ }
200
+ const normalized = normalizeDecision(decision)
201
+ if (!normalized.allow && firstDeny === undefined) {
202
+ firstDeny = {
203
+ action: 'deny',
204
+ probeName: entry.name,
205
+ reason: normalized.reason,
206
+ }
207
+ }
208
+ }
209
+
210
+ return firstDeny ?? { action: 'allow' }
211
+ }
212
+
213
+ clear(): void {
214
+ this.typedByKind.clear()
215
+ this.vetoByKind.clear()
216
+ this.catchAll.length = 0
217
+ this.byName.clear()
218
+ }
219
+
220
+ private runTier(entries: readonly ProbeEntry[], event: ProbeEvent, ctx: ProbeContext): void {
221
+ for (const entry of entries) {
222
+ if (entry.where && !entry.where(event)) continue
223
+ try {
224
+ entry.handler(event, ctx)
225
+ } catch (error) {
226
+ this.logThrow(entry.name ?? 'unnamed', event.type, error)
227
+ }
228
+ }
229
+ }
230
+
231
+ private makeEntry(
232
+ handler: (event: ProbeEvent, ctx: ProbeContext) => void,
233
+ opts: ProbeOptions,
234
+ ): ProbeEntry {
235
+ if (opts.name !== undefined) {
236
+ const existing = this.byName.get(opts.name)
237
+ if (existing && !opts.override) {
238
+ throw new ProbeNameCollisionError(opts.name)
239
+ }
240
+ if (existing && opts.override) this.removeAnywhere(existing)
241
+ }
242
+
243
+ const entry: ProbeEntry = {
244
+ id: this.nextId++,
245
+ name: opts.name,
246
+ priority: opts.priority ?? 0,
247
+ handler,
248
+ where: opts.where as ((event: ProbeEvent) => boolean) | undefined,
249
+ }
250
+
251
+ if (entry.name) this.byName.set(entry.name, entry)
252
+ return entry
253
+ }
254
+
255
+ private removeAnywhere(entry: ProbeEntry | VetoEntry): void {
256
+ for (const bucket of this.typedByKind.values()) removeEntry(bucket, entry as ProbeEntry)
257
+ removeEntry(this.catchAll, entry as ProbeEntry)
258
+ for (const bucket of this.vetoByKind.values()) removeVeto(bucket, entry as VetoEntry)
259
+ if (entry.name) this.byName.delete(entry.name)
260
+ }
261
+
262
+ private logThrow(probeName: string, eventType: string, error: unknown): void {
263
+ if (!this.log) return
264
+ this.log.error('probe handler threw', {
265
+ probeName,
266
+ eventType,
267
+ error: error instanceof Error ? error.message : String(error),
268
+ })
269
+ }
270
+ }
271
+
272
+ export const probe: ProbeRegistry = new ProbeRegistry()
273
+
274
+ export function createProbeRegistry(): ProbeRegistry {
275
+ return new ProbeRegistry()
276
+ }
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { buildProbeContext } from '../probe/context.js'
4
+ import { createProbeRegistry } from '../probe/registry.js'
5
+ import type { AgentBusEvent } from '../types/bus/index.js'
6
+ import type { ChatCompletionParams, ChatCompletionResponse } from '../types/provider/chat.js'
7
+ import type { LLMProvider } from '../types/provider/interface.js'
8
+ import type { StreamChunk } from '../types/provider/stream.js'
9
+
10
+ import { wrapProviderWithProbes } from './instrumentation.js'
11
+
12
+ function makeFakeProvider(
13
+ overrides: Partial<{
14
+ chat: LLMProvider['chat']
15
+ chatStream: LLMProvider['chatStream']
16
+ }> = {},
17
+ ): LLMProvider {
18
+ const defaultChat: LLMProvider['chat'] = async (
19
+ _params: ChatCompletionParams,
20
+ ): Promise<ChatCompletionResponse> => {
21
+ return {
22
+ content: 'ok',
23
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
24
+ } as unknown as ChatCompletionResponse
25
+ }
26
+ const defaultStream: LLMProvider['chatStream'] = async function* (
27
+ _params: ChatCompletionParams,
28
+ ): AsyncIterable<StreamChunk> {
29
+ yield { delta: 'hi' } as unknown as StreamChunk
30
+ }
31
+ return {
32
+ id: 'p1',
33
+ name: 'Provider 1',
34
+ chat: overrides.chat ?? defaultChat,
35
+ chatStream: overrides.chatStream ?? defaultStream,
36
+ }
37
+ }
38
+
39
+ const params: ChatCompletionParams = { model: 'm1', messages: [] } as ChatCompletionParams
40
+
41
+ describe('wrapProviderWithProbes — chat', () => {
42
+ it('emits provider_call_start before the chat call and provider_call_completed after', async () => {
43
+ const reg = createProbeRegistry()
44
+ const seen: AgentBusEvent[] = []
45
+ reg.onAny((event) => seen.push(event as AgentBusEvent))
46
+
47
+ const wrapped = wrapProviderWithProbes(makeFakeProvider(), { probes: reg })
48
+ await wrapped.chat(params)
49
+
50
+ expect(seen.map((e) => e.type)).toEqual(['provider_call_start', 'provider_call_completed'])
51
+ const start = seen[0] as AgentBusEvent & { type: 'provider_call_start' }
52
+ const completed = seen[1] as AgentBusEvent & { type: 'provider_call_completed' }
53
+ expect(start.providerId).toBe('p1')
54
+ expect(start.model).toBe('m1')
55
+ expect(completed.callId).toBe(start.callId)
56
+ expect(completed.usage).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15 })
57
+ expect(completed.durationMs).toBeGreaterThanOrEqual(0)
58
+ })
59
+
60
+ it('emits provider_call_failed and re-throws when chat throws', async () => {
61
+ const reg = createProbeRegistry()
62
+ const seen: AgentBusEvent[] = []
63
+ reg.onAny((event) => seen.push(event as AgentBusEvent))
64
+
65
+ const failing = makeFakeProvider({
66
+ chat: async () => {
67
+ throw new Error('boom')
68
+ },
69
+ })
70
+ const wrapped = wrapProviderWithProbes(failing, { probes: reg })
71
+
72
+ await expect(wrapped.chat(params)).rejects.toThrow('boom')
73
+ expect(seen.map((e) => e.type)).toEqual(['provider_call_start', 'provider_call_failed'])
74
+ const failed = seen[1] as AgentBusEvent & { type: 'provider_call_failed' }
75
+ expect(failed.error).toBe('boom')
76
+ })
77
+
78
+ it('correlates start and completed by callId', async () => {
79
+ const reg = createProbeRegistry()
80
+ const ids: string[] = []
81
+ reg.on('provider_call_start', (event) => ids.push(`s:${event.callId}`))
82
+ reg.on('provider_call_completed', (event) => ids.push(`c:${event.callId}`))
83
+
84
+ const wrapped = wrapProviderWithProbes(makeFakeProvider(), { probes: reg })
85
+ await wrapped.chat(params)
86
+ await wrapped.chat(params)
87
+
88
+ expect(ids.length).toBe(4)
89
+ expect(ids[0]?.split(':')[1]).toBe(ids[1]?.split(':')[1])
90
+ expect(ids[2]?.split(':')[1]).toBe(ids[3]?.split(':')[1])
91
+ expect(ids[0]).not.toBe(ids[2])
92
+ })
93
+
94
+ it('forwards optional methods (listModels, healthCheck) to the inner provider', () => {
95
+ const listModels = vi.fn()
96
+ const healthCheck = vi.fn()
97
+ const inner = { ...makeFakeProvider(), listModels, healthCheck }
98
+ const wrapped = wrapProviderWithProbes(inner)
99
+
100
+ wrapped.listModels?.()
101
+ wrapped.healthCheck?.()
102
+ expect(listModels).toHaveBeenCalledTimes(1)
103
+ expect(healthCheck).toHaveBeenCalledTimes(1)
104
+ })
105
+ })
106
+
107
+ describe('wrapProviderWithProbes — chatStream', () => {
108
+ it('emits provider_call_start before iteration and provider_call_completed after', async () => {
109
+ const reg = createProbeRegistry()
110
+ const seen: AgentBusEvent[] = []
111
+ reg.onAny((event) => seen.push(event as AgentBusEvent))
112
+
113
+ const wrapped = wrapProviderWithProbes(makeFakeProvider(), { probes: reg })
114
+ const chunks: StreamChunk[] = []
115
+ for await (const chunk of wrapped.chatStream(params)) {
116
+ chunks.push(chunk)
117
+ }
118
+
119
+ expect(chunks.length).toBe(1)
120
+ expect(seen.map((e) => e.type)).toEqual(['provider_call_start', 'provider_call_completed'])
121
+ })
122
+
123
+ it('emits provider_call_failed when the underlying stream throws mid-iteration', async () => {
124
+ const reg = createProbeRegistry()
125
+ const seen: AgentBusEvent[] = []
126
+ reg.onAny((event) => seen.push(event as AgentBusEvent))
127
+
128
+ const failing = makeFakeProvider({
129
+ chatStream: async function* (_params: ChatCompletionParams): AsyncIterable<StreamChunk> {
130
+ yield { delta: 'a' } as unknown as StreamChunk
131
+ throw new Error('stream-boom')
132
+ },
133
+ })
134
+ const wrapped = wrapProviderWithProbes(failing, { probes: reg })
135
+
136
+ await expect(async () => {
137
+ for await (const _chunk of wrapped.chatStream(params)) {
138
+ // noop
139
+ }
140
+ }).rejects.toThrow('stream-boom')
141
+
142
+ expect(seen.map((e) => e.type)).toEqual(['provider_call_start', 'provider_call_failed'])
143
+ })
144
+ })
145
+
146
+ describe('wrapProviderWithProbes — runId propagation', () => {
147
+ it('attaches runId to each emitted event when supplied', async () => {
148
+ const reg = createProbeRegistry()
149
+ let observedRunId: string | undefined
150
+ reg.on('provider_call_start', (event, ctx) => {
151
+ observedRunId = event.runId ?? ctx.runId
152
+ })
153
+
154
+ const wrapped = wrapProviderWithProbes(makeFakeProvider(), {
155
+ probes: reg,
156
+ runId: 'run_42' as never,
157
+ })
158
+ await wrapped.chat(params)
159
+ expect(observedRunId).toBe('run_42')
160
+ })
161
+ })
162
+
163
+ describe('wrapProviderWithProbes — uses singleton when no probes opt provided', () => {
164
+ it('still wraps successfully without throwing (smoke)', async () => {
165
+ // Use a fresh inner provider; we just want to verify the default path
166
+ // instantiates and runs. Singleton dispatch is exercised in registry tests.
167
+ const wrapped = wrapProviderWithProbes(makeFakeProvider())
168
+ await expect(wrapped.chat(params)).resolves.toBeDefined()
169
+ })
170
+ })
171
+
172
+ describe('wrapProviderWithProbes — context still flows through buildProbeContext', () => {
173
+ it('handler receives a frozen ctx', async () => {
174
+ const reg = createProbeRegistry()
175
+ let captured: Readonly<{ isReplay: boolean }> | undefined
176
+ reg.on('provider_call_start', (_event, ctx) => {
177
+ captured = ctx
178
+ })
179
+
180
+ const wrapped = wrapProviderWithProbes(makeFakeProvider(), { probes: reg })
181
+ await wrapped.chat(params)
182
+ expect(captured).toBeDefined()
183
+ expect(Object.isFrozen(captured)).toBe(true)
184
+ expect(captured?.isReplay).toBe(false)
185
+ })
186
+
187
+ it('buildProbeContext used internally returns a frozen ProbeContext (sanity check)', () => {
188
+ const ctx = buildProbeContext({ isReplay: true })
189
+ expect(ctx.isReplay).toBe(true)
190
+ expect(Object.isFrozen(ctx)).toBe(true)
191
+ })
192
+ })
@@ -0,0 +1,139 @@
1
+ import { buildProbeContext } from '../probe/context.js'
2
+ import { type ProbeRegistry, probe as defaultProbeRegistry } from '../probe/registry.js'
3
+ import type { ProviderCallId, ProviderCallUsage } from '../types/bus/index.js'
4
+ import type { RunId } from '../types/ids/index.js'
5
+ import type { ChatCompletionParams, ChatCompletionResponse } from '../types/provider/chat.js'
6
+ import type { LLMProvider } from '../types/provider/interface.js'
7
+ import type { StreamChunk } from '../types/provider/stream.js'
8
+
9
+ export interface ProviderInstrumentationOptions {
10
+ readonly probes?: ProbeRegistry
11
+ readonly runId?: RunId
12
+ }
13
+
14
+ let providerCallCounter = 0
15
+
16
+ function nextCallId(): ProviderCallId {
17
+ providerCallCounter += 1
18
+ return `pcall_${Date.now().toString(36)}${providerCallCounter.toString(36)}` as ProviderCallId
19
+ }
20
+
21
+ function extractUsage(response: ChatCompletionResponse): ProviderCallUsage | undefined {
22
+ const usage = (response as { usage?: ProviderCallUsage }).usage
23
+ if (!usage) return undefined
24
+ return {
25
+ inputTokens: usage.inputTokens,
26
+ outputTokens: usage.outputTokens,
27
+ totalTokens: usage.totalTokens,
28
+ costUsd: usage.costUsd,
29
+ }
30
+ }
31
+
32
+ export function wrapProviderWithProbes(
33
+ provider: LLMProvider,
34
+ opts: ProviderInstrumentationOptions = {},
35
+ ): LLMProvider {
36
+ const probes = opts.probes ?? defaultProbeRegistry
37
+ const runId = opts.runId
38
+
39
+ const wrapped: LLMProvider = {
40
+ id: provider.id,
41
+ name: provider.name,
42
+ listModels: provider.listModels?.bind(provider),
43
+ healthCheck: provider.healthCheck?.bind(provider),
44
+
45
+ async chat(params: ChatCompletionParams): Promise<ChatCompletionResponse> {
46
+ const callId = nextCallId()
47
+ const ctx = buildProbeContext({ runId })
48
+ const startedAt = Date.now()
49
+ probes.dispatch(
50
+ {
51
+ type: 'provider_call_start',
52
+ providerId: provider.id,
53
+ model: params.model,
54
+ callId,
55
+ runId,
56
+ },
57
+ ctx,
58
+ )
59
+ try {
60
+ const response = await provider.chat(params)
61
+ probes.dispatch(
62
+ {
63
+ type: 'provider_call_completed',
64
+ providerId: provider.id,
65
+ model: params.model,
66
+ callId,
67
+ runId,
68
+ durationMs: Date.now() - startedAt,
69
+ usage: extractUsage(response),
70
+ },
71
+ ctx,
72
+ )
73
+ return response
74
+ } catch (error) {
75
+ probes.dispatch(
76
+ {
77
+ type: 'provider_call_failed',
78
+ providerId: provider.id,
79
+ model: params.model,
80
+ callId,
81
+ runId,
82
+ durationMs: Date.now() - startedAt,
83
+ error: error instanceof Error ? error.message : String(error),
84
+ },
85
+ ctx,
86
+ )
87
+ throw error
88
+ }
89
+ },
90
+
91
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
92
+ const callId = nextCallId()
93
+ const ctx = buildProbeContext({ runId })
94
+ const startedAt = Date.now()
95
+ probes.dispatch(
96
+ {
97
+ type: 'provider_call_start',
98
+ providerId: provider.id,
99
+ model: params.model,
100
+ callId,
101
+ runId,
102
+ },
103
+ ctx,
104
+ )
105
+ try {
106
+ for await (const chunk of provider.chatStream(params)) {
107
+ yield chunk
108
+ }
109
+ probes.dispatch(
110
+ {
111
+ type: 'provider_call_completed',
112
+ providerId: provider.id,
113
+ model: params.model,
114
+ callId,
115
+ runId,
116
+ durationMs: Date.now() - startedAt,
117
+ },
118
+ ctx,
119
+ )
120
+ } catch (error) {
121
+ probes.dispatch(
122
+ {
123
+ type: 'provider_call_failed',
124
+ providerId: provider.id,
125
+ model: params.model,
126
+ callId,
127
+ runId,
128
+ durationMs: Date.now() - startedAt,
129
+ error: error instanceof Error ? error.message : String(error),
130
+ },
131
+ ctx,
132
+ )
133
+ throw error
134
+ }
135
+ },
136
+ }
137
+
138
+ return wrapped
139
+ }
@@ -222,6 +222,28 @@ export {
222
222
 
223
223
  export { evaluateRule, VerificationGate } from './verification/index.js'
224
224
 
225
+ // ─── probe (typed observation over AgentBus + RunEvent stream) ───────────
226
+
227
+ export {
228
+ buildProbeContext,
229
+ createProbeRegistry,
230
+ probe,
231
+ ProbeNameCollisionError,
232
+ ProbeRegistry,
233
+ ProbeVetoError,
234
+ } from './probe/index.js'
235
+
236
+ export { wrapProviderWithProbes } from './provider/instrumentation.js'
237
+ export type { ProviderInstrumentationOptions } from './provider/instrumentation.js'
238
+
239
+ export { wrapVaultWithProbes } from './vault/instrumentation.js'
240
+ export type { VaultInstrumentationOptions } from './vault/instrumentation.js'
241
+
242
+ // Doctor runtime moved to @namzu/cli in 0.5.0. SDK keeps only the
243
+ // protocol types under `types/doctor/` (re-exported via public-types.ts)
244
+ // + `LLMProvider.doctorCheck?()` hook on the provider interface.
245
+ // Operators run `npx @namzu/cli doctor`; embedded usage lives there too.
246
+
225
247
  // ─── session runtime — explicit named lists, no `export *` ───────────────
226
248
  // See §1.5 + §4.2 of design.md. Types flow through public-types.ts.
227
249
 
@@ -41,6 +41,9 @@ export type * from './types/structured-output/index.js'
41
41
  export type * from './types/invocation/index.js'
42
42
  export type * from './types/computer-use/index.js'
43
43
  export type * from './types/verification/index.js'
44
+ export type * from './types/bus/index.js'
45
+ export type * from './types/probe/index.js'
46
+ export type * from './types/doctor/index.js'
44
47
 
45
48
  // Session-hierarchy type surface (ses_010 moved entities here).
46
49
  export type * from './types/session/index.js'
@@ -1,5 +1,7 @@
1
1
  import type { PlanEvent, PlanManager } from '../../manager/plan/lifecycle.js'
2
2
  import type { RunPersistence } from '../../manager/run/persistence.js'
3
+ import { buildProbeContext } from '../../probe/context.js'
4
+ import { type ProbeRegistry, probe as defaultProbeRegistry } from '../../probe/registry.js'
3
5
  import type { ActivityEvent, ActivityStore } from '../../store/activity/memory.js'
4
6
  import type { RunId } from '../../types/ids/index.js'
5
7
  import type { RunEvent } from '../../types/run/index.js'
@@ -10,12 +12,15 @@ export type EmitEvent = (event: RunEvent) => Promise<void>
10
12
  export class EventTranslator {
11
13
  private pendingEvents: RunEvent[] = []
12
14
  private runMgr: RunPersistence
15
+ private probes: ProbeRegistry
13
16
 
14
- constructor(runMgr: RunPersistence) {
17
+ constructor(runMgr: RunPersistence, probeRegistry: ProbeRegistry = defaultProbeRegistry) {
15
18
  this.runMgr = runMgr
19
+ this.probes = probeRegistry
16
20
  }
17
21
 
18
22
  readonly emitEvent: EmitEvent = async (event: RunEvent): Promise<void> => {
23
+ this.probes.dispatch(event, buildProbeContext({ runId: event.runId }))
19
24
  this.pendingEvents.push(event)
20
25
  await this.runMgr.getRunStore().appendEvent(event)
21
26
  };