@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.
- package/CHANGELOG.md +129 -0
- package/dist/bridge/tools/connector/adapter.d.ts +2 -2
- package/dist/bus/index.d.ts +3 -1
- package/dist/bus/index.d.ts.map +1 -1
- package/dist/bus/index.js +18 -11
- package/dist/bus/index.js.map +1 -1
- package/dist/config/runtime.d.ts +28 -28
- package/dist/probe/context.d.ts +8 -0
- package/dist/probe/context.d.ts.map +1 -0
- package/dist/probe/context.js +7 -0
- package/dist/probe/context.js.map +1 -0
- package/dist/probe/errors.d.ts +12 -0
- package/dist/probe/errors.d.ts.map +1 -0
- package/dist/probe/errors.js +21 -0
- package/dist/probe/errors.js.map +1 -0
- package/dist/probe/index.d.ts +5 -0
- package/dist/probe/index.d.ts.map +1 -0
- package/dist/probe/index.js +4 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe/registry.d.ts +24 -0
- package/dist/probe/registry.d.ts.map +1 -0
- package/dist/probe/registry.js +228 -0
- package/dist/probe/registry.js.map +1 -0
- package/dist/probe/registry.test.d.ts +7 -0
- package/dist/probe/registry.test.d.ts.map +1 -0
- package/dist/probe/registry.test.js +310 -0
- package/dist/probe/registry.test.js.map +1 -0
- package/dist/provider/instrumentation.d.ts +9 -0
- package/dist/provider/instrumentation.d.ts.map +1 -0
- package/dist/provider/instrumentation.js +104 -0
- package/dist/provider/instrumentation.js.map +1 -0
- package/dist/provider/instrumentation.test.d.ts +2 -0
- package/dist/provider/instrumentation.test.d.ts.map +1 -0
- package/dist/provider/instrumentation.test.js +152 -0
- package/dist/provider/instrumentation.test.js.map +1 -0
- package/dist/public-runtime.d.ts +5 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +8 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +3 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/events.d.ts +3 -1
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +6 -1
- package/dist/runtime/query/events.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +3 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +30 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/types/bus/index.d.ts +46 -2
- package/dist/types/bus/index.d.ts.map +1 -1
- package/dist/types/doctor/check.d.ts +41 -0
- package/dist/types/doctor/check.d.ts.map +1 -0
- package/dist/types/doctor/check.js +2 -0
- package/dist/types/doctor/check.js.map +1 -0
- package/dist/types/doctor/index.d.ts +2 -0
- package/dist/types/doctor/index.d.ts.map +1 -0
- package/dist/types/doctor/index.js +2 -0
- package/dist/types/doctor/index.js.map +1 -0
- package/dist/types/probe/event-kind.d.ts +6 -0
- package/dist/types/probe/event-kind.d.ts.map +1 -0
- package/dist/types/probe/event-kind.js +2 -0
- package/dist/types/probe/event-kind.js.map +1 -0
- package/dist/types/probe/event-of.d.ts +5 -0
- package/dist/types/probe/event-of.d.ts.map +1 -0
- package/dist/types/probe/event-of.js +2 -0
- package/dist/types/probe/event-of.js.map +1 -0
- package/dist/types/probe/index.d.ts +4 -0
- package/dist/types/probe/index.d.ts.map +1 -0
- package/dist/types/probe/index.js +2 -0
- package/dist/types/probe/index.js.map +1 -0
- package/dist/types/probe/registry.d.ts +27 -0
- package/dist/types/probe/registry.d.ts.map +1 -0
- package/dist/types/probe/registry.js +2 -0
- package/dist/types/probe/registry.js.map +1 -0
- package/dist/types/provider/interface.d.ts +10 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/vault/instrumentation.d.ts +11 -0
- package/dist/vault/instrumentation.d.ts.map +1 -0
- package/dist/vault/instrumentation.js +32 -0
- package/dist/vault/instrumentation.js.map +1 -0
- package/dist/vault/instrumentation.test.d.ts +2 -0
- package/dist/vault/instrumentation.test.d.ts.map +1 -0
- package/dist/vault/instrumentation.test.js +80 -0
- package/dist/vault/instrumentation.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bus/index.ts +21 -10
- package/src/probe/context.ts +14 -0
- package/src/probe/errors.ts +27 -0
- package/src/probe/index.ts +4 -0
- package/src/probe/registry.test.ts +480 -0
- package/src/probe/registry.ts +276 -0
- package/src/provider/instrumentation.test.ts +192 -0
- package/src/provider/instrumentation.ts +139 -0
- package/src/public-runtime.ts +22 -0
- package/src/public-types.ts +3 -0
- package/src/runtime/query/events.ts +6 -1
- package/src/runtime/query/executor.ts +34 -0
- package/src/types/bus/index.ts +54 -2
- package/src/types/doctor/check.ts +53 -0
- package/src/types/doctor/index.ts +9 -0
- package/src/types/probe/event-kind.ts +8 -0
- package/src/types/probe/event-of.ts +3 -0
- package/src/types/probe/index.ts +11 -0
- package/src/types/probe/registry.ts +36 -0
- package/src/types/provider/interface.ts +12 -0
- package/src/vault/instrumentation.test.ts +98 -0
- 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
|
+
}
|
package/src/public-runtime.ts
CHANGED
|
@@ -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
|
|
package/src/public-types.ts
CHANGED
|
@@ -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
|
};
|