dsh-agora-plugin 0.6.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 (79) hide show
  1. package/README.md +520 -0
  2. package/client/index.js +492 -0
  3. package/cordis.patch.yml +14 -0
  4. package/dsh.plugin.json +7 -0
  5. package/lib/agora-client.d.ts +73 -0
  6. package/lib/agora-client.d.ts.map +1 -0
  7. package/lib/agora-client.js +207 -0
  8. package/lib/agora-client.js.map +1 -0
  9. package/lib/client.js +492 -0
  10. package/lib/command-adapter.d.ts +44 -0
  11. package/lib/command-adapter.d.ts.map +1 -0
  12. package/lib/command-adapter.js +70 -0
  13. package/lib/command-adapter.js.map +1 -0
  14. package/lib/command.d.ts +53 -0
  15. package/lib/command.d.ts.map +1 -0
  16. package/lib/command.js +375 -0
  17. package/lib/command.js.map +1 -0
  18. package/lib/context-types.d.ts +51 -0
  19. package/lib/context-types.d.ts.map +1 -0
  20. package/lib/context-types.js +2 -0
  21. package/lib/context-types.js.map +1 -0
  22. package/lib/contracts.d.ts +402 -0
  23. package/lib/contracts.d.ts.map +1 -0
  24. package/lib/contracts.js +2 -0
  25. package/lib/contracts.js.map +1 -0
  26. package/lib/extension-sdk.d.ts +74 -0
  27. package/lib/extension-sdk.d.ts.map +1 -0
  28. package/lib/extension-sdk.js +160 -0
  29. package/lib/extension-sdk.js.map +1 -0
  30. package/lib/harness-runtime.d.ts +29 -0
  31. package/lib/harness-runtime.d.ts.map +1 -0
  32. package/lib/harness-runtime.js +422 -0
  33. package/lib/harness-runtime.js.map +1 -0
  34. package/lib/http-api.d.ts +10 -0
  35. package/lib/http-api.d.ts.map +1 -0
  36. package/lib/http-api.js +282 -0
  37. package/lib/http-api.js.map +1 -0
  38. package/lib/im-bridge-v1.d.ts +37 -0
  39. package/lib/im-bridge-v1.d.ts.map +1 -0
  40. package/lib/im-bridge-v1.js +43 -0
  41. package/lib/im-bridge-v1.js.map +1 -0
  42. package/lib/im-gateway.d.ts +24 -0
  43. package/lib/im-gateway.d.ts.map +1 -0
  44. package/lib/im-gateway.js +53 -0
  45. package/lib/im-gateway.js.map +1 -0
  46. package/lib/index.d.ts +46 -0
  47. package/lib/index.d.ts.map +1 -0
  48. package/lib/index.js +210 -0
  49. package/lib/index.js.map +1 -0
  50. package/lib/node-worker.d.ts +52 -0
  51. package/lib/node-worker.d.ts.map +1 -0
  52. package/lib/node-worker.js +372 -0
  53. package/lib/node-worker.js.map +1 -0
  54. package/lib/service.d.ts +51 -0
  55. package/lib/service.d.ts.map +1 -0
  56. package/lib/service.js +196 -0
  57. package/lib/service.js.map +1 -0
  58. package/lib/tool.d.ts +72 -0
  59. package/lib/tool.d.ts.map +1 -0
  60. package/lib/tool.js +206 -0
  61. package/lib/tool.js.map +1 -0
  62. package/package.json +74 -0
  63. package/patches/dsh-im/@xmanrui__dsh-im@2.1.0.patch +920 -0
  64. package/patches/dsh-im/@xmanrui__dsh-im@2.3.0.patch +984 -0
  65. package/patches/dsh-im/README.md +82 -0
  66. package/src/agora-client.ts +333 -0
  67. package/src/command-adapter.ts +106 -0
  68. package/src/command.ts +424 -0
  69. package/src/context-types.ts +54 -0
  70. package/src/contracts.ts +413 -0
  71. package/src/extension-sdk.ts +225 -0
  72. package/src/harness-runtime.ts +518 -0
  73. package/src/http-api.ts +269 -0
  74. package/src/im-bridge-v1.ts +73 -0
  75. package/src/im-gateway.ts +82 -0
  76. package/src/index.ts +260 -0
  77. package/src/node-worker.ts +442 -0
  78. package/src/service.ts +262 -0
  79. package/src/tool.ts +269 -0
@@ -0,0 +1,442 @@
1
+ import { AgoraApiError, type AgoraClient } from './agora-client.js'
2
+ import {
3
+ DSH_AGORA_NODE_PROTOCOL,
4
+ type DshAgoraNodeStatus,
5
+ type RuntimeDispatch,
6
+ type RuntimeDelivery,
7
+ type RuntimeNodeBot,
8
+ type RuntimeDispatchProgressInput,
9
+ type RuntimeResultEnvelope,
10
+ } from './contracts.js'
11
+ import type { DshAgoraExtensionRegistry } from './extension-sdk.js'
12
+ import type { DshImBridgeV1, DshImSendRequestV1 } from './im-bridge-v1.js'
13
+
14
+ export interface RuntimeNodeWorkerOptions {
15
+ readonly client: AgoraClient
16
+ readonly registry: DshAgoraExtensionRegistry
17
+ readonly nodeId: string
18
+ readonly instanceId: string
19
+ readonly pluginVersion: string
20
+ readonly heartbeatIntervalMs?: number
21
+ readonly dispatchPollIntervalMs?: number
22
+ readonly leaseSeconds?: number
23
+ readonly dispatchLeaseSeconds?: number
24
+ readonly dispatchRenewIntervalMs?: number
25
+ readonly deliveryPollIntervalMs?: number
26
+ readonly deliveryLeaseSeconds?: number
27
+ readonly maxConcurrent?: number
28
+ readonly imBridge?: DshImBridgeV1 | null
29
+ readonly metadata?: Readonly<Record<string, unknown>>
30
+ onStatus?(status: DshAgoraNodeStatus): void
31
+ }
32
+
33
+ export class RuntimeNodeWorker {
34
+ private readonly abortController = new AbortController()
35
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
36
+ private dispatchTimer: ReturnType<typeof setTimeout> | null = null
37
+ private deliveryTimer: ReturnType<typeof setTimeout> | null = null
38
+ private active = 0
39
+ private started = false
40
+ private imBridge: DshImBridgeV1 | null
41
+
42
+ constructor(private readonly options: RuntimeNodeWorkerOptions) {
43
+ this.imBridge = options.imBridge ?? null
44
+ }
45
+
46
+ setImBridge(bridge: DshImBridgeV1 | null): void {
47
+ this.imBridge = bridge
48
+ }
49
+
50
+ start(): void {
51
+ if (this.started) return
52
+ this.started = true
53
+ this.setStatus({ state: 'connecting', nodeId: this.options.nodeId })
54
+ void this.heartbeatLoop()
55
+ void this.dispatchLoop()
56
+ void this.deliveryLoop()
57
+ }
58
+
59
+ stop(): void {
60
+ if (!this.started) return
61
+ this.started = false
62
+ this.abortController.abort(new DOMException('dsh-agora node worker stopped', 'AbortError'))
63
+ if (this.heartbeatTimer !== null) clearTimeout(this.heartbeatTimer)
64
+ if (this.dispatchTimer !== null) clearTimeout(this.dispatchTimer)
65
+ if (this.deliveryTimer !== null) clearTimeout(this.deliveryTimer)
66
+ this.heartbeatTimer = null
67
+ this.dispatchTimer = null
68
+ this.deliveryTimer = null
69
+ }
70
+
71
+ private async heartbeatLoop(): Promise<void> {
72
+ if (this.abortController.signal.aborted) return
73
+ try {
74
+ const agents = (await Promise.all(
75
+ this.options.registry.listExtensions()
76
+ .filter(extension => extension.runtime !== undefined)
77
+ .map(extension => extension.runtime!.describeAgents()),
78
+ )).flat()
79
+ if (agents.length === 0) throw new Error('no runtime agents are registered')
80
+ const bots = await this.listBots()
81
+ const now = new Date().toISOString()
82
+ await this.options.client.heartbeatRuntimeNode(this.options.nodeId, {
83
+ protocol: DSH_AGORA_NODE_PROTOCOL,
84
+ instance_id: this.options.instanceId,
85
+ plugin_version: this.options.pluginVersion,
86
+ host_framework: 'deepseek-harness',
87
+ runtime_provider: 'dsh',
88
+ agents,
89
+ bots,
90
+ capacity: { max_concurrent: this.maxConcurrent, active: this.active },
91
+ lease_seconds: this.leaseSeconds,
92
+ metadata: this.options.metadata ?? null,
93
+ }, this.abortController.signal)
94
+ this.setStatus({ state: 'online', nodeId: this.options.nodeId, lastHeartbeatAt: now })
95
+ } catch (error) {
96
+ if (!this.abortController.signal.aborted) {
97
+ this.setStatus({
98
+ state: 'error', nodeId: this.options.nodeId,
99
+ error: error instanceof Error ? error.message : String(error),
100
+ })
101
+ }
102
+ } finally {
103
+ this.heartbeatTimer = schedule(
104
+ () => void this.heartbeatLoop(),
105
+ this.options.heartbeatIntervalMs ?? 30_000,
106
+ this.abortController.signal,
107
+ )
108
+ }
109
+ }
110
+
111
+ private async dispatchLoop(): Promise<void> {
112
+ if (this.abortController.signal.aborted) return
113
+ try {
114
+ while (this.active < this.maxConcurrent && !this.abortController.signal.aborted) {
115
+ const dispatch = await this.options.client.claimRuntimeDispatch(
116
+ this.options.nodeId,
117
+ this.options.instanceId,
118
+ this.dispatchLeaseSeconds,
119
+ this.abortController.signal,
120
+ )
121
+ if (!dispatch) break
122
+ this.active += 1
123
+ void this.execute(dispatch).finally(() => { this.active -= 1 })
124
+ }
125
+ } catch {
126
+ // Heartbeat owns the operator-visible status. Dispatch polling is retried
127
+ // independently so a transient queue error does not flap node presence.
128
+ } finally {
129
+ this.dispatchTimer = schedule(
130
+ () => void this.dispatchLoop(),
131
+ this.options.dispatchPollIntervalMs ?? 2_000,
132
+ this.abortController.signal,
133
+ )
134
+ }
135
+ }
136
+
137
+ private async execute(dispatch: RuntimeDispatch): Promise<void> {
138
+ const runtime = this.options.registry.runtimeForTarget(dispatch.runtime_target_ref)
139
+ if (!runtime) {
140
+ await this.fail(dispatch, `no runtime adapter accepts ${dispatch.runtime_target_ref}`)
141
+ return
142
+ }
143
+ if (!dispatch.claim_token) {
144
+ await this.fail(dispatch, 'claimed dispatch has no fencing token')
145
+ return
146
+ }
147
+ const renewalAbort = new AbortController()
148
+ const leaseLostAbort = new AbortController()
149
+ const executionSignal = AbortSignal.any([
150
+ this.abortController.signal,
151
+ leaseLostAbort.signal,
152
+ ])
153
+ const renewal = this.renewLease(dispatch, renewalAbort.signal).catch(error => {
154
+ if (!renewalAbort.signal.aborted && !this.abortController.signal.aborted) {
155
+ leaseLostAbort.abort(error)
156
+ }
157
+ })
158
+ let progressSequence = 0
159
+ let progressSupported = true
160
+ const reportProgress = async (event: RuntimeDispatchProgressInput): Promise<void> => {
161
+ if (!progressSupported || !dispatch.claim_token) return
162
+ progressSequence += 1
163
+ try {
164
+ await this.options.client.recordRuntimeDispatchProgress(
165
+ this.options.nodeId,
166
+ dispatch.id,
167
+ {
168
+ instance_id: this.options.instanceId,
169
+ claim_token: dispatch.claim_token,
170
+ sequence: progressSequence,
171
+ ...event,
172
+ },
173
+ executionSignal,
174
+ )
175
+ } catch (error) {
176
+ if (error instanceof AgoraApiError && (error.status === 404 || error.status === 405)) {
177
+ progressSupported = false
178
+ return
179
+ }
180
+ if (error instanceof AgoraApiError && error.status === 409) throw error
181
+ // Progress telemetry must not make durable work fail during a transient
182
+ // network outage; the independently renewed lease remains authoritative.
183
+ }
184
+ }
185
+ try {
186
+ await reportProgress({ phase: 'claimed', message: 'Dispatch claimed by runtime node', percent: 0 })
187
+ const result = await runtime.execute(dispatch, executionSignal, { reportProgress })
188
+ await reportProgress({ phase: 'finalizing', message: 'Persisting the agent result', percent: 95 })
189
+ renewalAbort.abort()
190
+ await renewal
191
+ if (leaseLostAbort.signal.aborted) return
192
+ const deliveryPayload = this.presentationPayload(dispatch, result.answer)
193
+ await this.options.client.completeRuntimeDispatch(
194
+ this.options.nodeId,
195
+ dispatch.id,
196
+ {
197
+ instance_id: this.options.instanceId,
198
+ claim_token: dispatch.claim_token,
199
+ status: 'completed',
200
+ session_id: result.sessionId,
201
+ result: {
202
+ answer: result.answer,
203
+ reason: result.reason ?? null,
204
+ ...(result.metadata ?? {}),
205
+ },
206
+ result_envelope: result.resultEnvelope ?? defaultResultEnvelope(dispatch, result.answer),
207
+ ...(deliveryPayload === null ? {} : { delivery_payload: deliveryPayload }),
208
+ },
209
+ this.abortController.signal,
210
+ )
211
+ if (dispatch.task_id && dispatch.participant_binding_id) {
212
+ try {
213
+ await this.options.client.bindRuntimeSession(
214
+ dispatch.task_id,
215
+ dispatch.participant_binding_id,
216
+ result.sessionId,
217
+ dispatch.runtime_target_ref,
218
+ this.abortController.signal,
219
+ )
220
+ } catch {
221
+ // The durable result is authoritative. Session binding is repaired by
222
+ // the normal reconciliation path instead of rolling back completion.
223
+ }
224
+ }
225
+ } catch (error) {
226
+ renewalAbort.abort()
227
+ await renewal
228
+ if (this.abortController.signal.aborted || leaseLostAbort.signal.aborted) return
229
+ await this.fail(dispatch, error instanceof Error ? error.message : String(error))
230
+ }
231
+ }
232
+
233
+ private async deliveryLoop(): Promise<void> {
234
+ if (this.abortController.signal.aborted) return
235
+ try {
236
+ while (!this.abortController.signal.aborted) {
237
+ const delivery = await this.options.client.claimRuntimeDelivery(
238
+ this.options.nodeId,
239
+ this.options.instanceId,
240
+ this.deliveryLeaseSeconds,
241
+ this.abortController.signal,
242
+ )
243
+ if (!delivery) break
244
+ await this.deliver(delivery)
245
+ }
246
+ } catch {
247
+ // Durable delivery remains pending or is released when its lease expires.
248
+ } finally {
249
+ this.deliveryTimer = schedule(
250
+ () => void this.deliveryLoop(),
251
+ this.options.deliveryPollIntervalMs ?? 2_000,
252
+ this.abortController.signal,
253
+ )
254
+ }
255
+ }
256
+
257
+ private async renewLease(dispatch: RuntimeDispatch, signal: AbortSignal): Promise<void> {
258
+ const claimToken = dispatch.claim_token
259
+ if (!claimToken) throw new Error(`runtime dispatch ${dispatch.id} has no fencing token`)
260
+ while (!signal.aborted && !this.abortController.signal.aborted) {
261
+ await sleep(this.dispatchRenewIntervalMs, AbortSignal.any([signal, this.abortController.signal]))
262
+ await this.options.client.renewRuntimeDispatch(
263
+ this.options.nodeId,
264
+ dispatch.id,
265
+ this.options.instanceId,
266
+ claimToken,
267
+ this.dispatchLeaseSeconds,
268
+ AbortSignal.any([signal, this.abortController.signal]),
269
+ )
270
+ }
271
+ }
272
+
273
+ private async fail(dispatch: RuntimeDispatch, error: string): Promise<void> {
274
+ try {
275
+ if (!dispatch.claim_token) return
276
+ await this.options.client.completeRuntimeDispatch(
277
+ this.options.nodeId,
278
+ dispatch.id,
279
+ { instance_id: this.options.instanceId, claim_token: dispatch.claim_token, status: 'failed', error },
280
+ this.abortController.signal,
281
+ )
282
+ } catch {
283
+ // The claim lease will expire and make the durable dispatch retryable.
284
+ }
285
+ }
286
+
287
+ private presentationPayload(dispatch: RuntimeDispatch, answer: string): Record<string, unknown> | null {
288
+ if (!answer.trim()) return null
289
+ const metadata = asRecord(dispatch.metadata)
290
+ const target = asRecord(metadata?.presentation_target)
291
+ if (!target || target.mode !== 'destination_bot') return null
292
+ if (!stringValue(target.provider) || !stringValue(target.conversation_ref)) return null
293
+ return {
294
+ protocol: 'dsh-agora.presentation/v1',
295
+ runtime_target_ref: dispatch.runtime_target_ref,
296
+ text: answer,
297
+ target,
298
+ }
299
+ }
300
+
301
+ private async deliver(delivery: RuntimeDelivery): Promise<void> {
302
+ const claimToken = delivery.claim_token
303
+ if (!claimToken) return
304
+ try {
305
+ const payload = asRecord(delivery.payload)
306
+ if (payload?.protocol !== 'dsh-agora.presentation/v1') {
307
+ throw new Error('unsupported runtime delivery payload')
308
+ }
309
+ const target = asRecord(payload.target)
310
+ const text = stringValue(payload.text)
311
+ const runtimeTargetRef = stringValue(payload.runtime_target_ref)
312
+ if (!target || !text || !runtimeTargetRef) throw new Error('invalid presentation delivery payload')
313
+ const bridge = this.imBridge
314
+ if (!bridge) throw new Error('dsh-im bridge is unavailable')
315
+ const provider = stringValue(target.provider)
316
+ const conversationRef = stringValue(target.conversation_ref)
317
+ if (!provider || !conversationRef) throw new Error('presentation target is incomplete')
318
+ const bots = await bridge.listBots()
319
+ const agentRef = runtimeTargetRef.split(':').at(-1)
320
+ const bot = bots.find(item => item.connected && item.provider === provider && item.agent_ref === agentRef)
321
+ ?? bots.find(item => item.connected && item.provider === provider)
322
+ if (!bot) throw new Error(`no connected ${provider} bot on destination node`)
323
+ const request: DshImSendRequestV1 = {
324
+ provider,
325
+ bot_ref: bot.bot_ref,
326
+ conversation_ref: conversationRef,
327
+ thread_ref: stringValue(target.thread_ref),
328
+ reply_to_message_ref: stringValue(target.reply_to_message_ref),
329
+ text,
330
+ idempotency_key: `agora:${delivery.dispatch_id}:result`,
331
+ }
332
+ const receipt = await bridge.send(request)
333
+ await this.options.client.completeRuntimeDelivery(
334
+ this.options.nodeId,
335
+ delivery.id,
336
+ {
337
+ instance_id: this.options.instanceId,
338
+ claim_token: claimToken,
339
+ status: 'delivered',
340
+ receipt: { provider_message_refs: receipt.provider_message_refs },
341
+ },
342
+ this.abortController.signal,
343
+ )
344
+ } catch (error) {
345
+ if (this.abortController.signal.aborted) return
346
+ try {
347
+ await this.options.client.completeRuntimeDelivery(
348
+ this.options.nodeId,
349
+ delivery.id,
350
+ {
351
+ instance_id: this.options.instanceId,
352
+ claim_token: claimToken,
353
+ status: 'retry',
354
+ error: error instanceof Error ? error.message : String(error),
355
+ retry_delay_seconds: deliveryRetryDelaySeconds(delivery.attempt),
356
+ },
357
+ this.abortController.signal,
358
+ )
359
+ } catch {
360
+ // The delivery lease expires and the same idempotency key is retried.
361
+ }
362
+ }
363
+ }
364
+
365
+ private async listBots(): Promise<readonly RuntimeNodeBot[]> {
366
+ try { return await this.imBridge?.listBots() ?? [] } catch { return [] }
367
+ }
368
+
369
+ private setStatus(status: DshAgoraNodeStatus): void {
370
+ this.options.onStatus?.(status)
371
+ }
372
+
373
+ private get leaseSeconds(): number {
374
+ return this.options.leaseSeconds ?? 90
375
+ }
376
+
377
+ private get maxConcurrent(): number {
378
+ return this.options.maxConcurrent ?? 1
379
+ }
380
+
381
+ private get dispatchLeaseSeconds(): number {
382
+ return this.options.dispatchLeaseSeconds ?? 120
383
+ }
384
+
385
+ private get dispatchRenewIntervalMs(): number {
386
+ return this.options.dispatchRenewIntervalMs ?? Math.max(1_000, Math.floor(this.dispatchLeaseSeconds * 1_000 / 3))
387
+ }
388
+
389
+ private get deliveryLeaseSeconds(): number {
390
+ return this.options.deliveryLeaseSeconds ?? 60
391
+ }
392
+ }
393
+
394
+ function schedule(callback: () => void, delay: number, signal: AbortSignal): ReturnType<typeof setTimeout> | null {
395
+ if (signal.aborted) return null
396
+ const timer = setTimeout(callback, delay)
397
+ timer.unref?.()
398
+ return timer
399
+ }
400
+
401
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
402
+ return new Promise((resolve, reject) => {
403
+ if (signal.aborted) {
404
+ reject(signal.reason)
405
+ return
406
+ }
407
+ const timer = setTimeout(resolve, ms)
408
+ timer.unref?.()
409
+ signal.addEventListener('abort', () => {
410
+ clearTimeout(timer)
411
+ reject(signal.reason)
412
+ }, { once: true })
413
+ })
414
+ }
415
+
416
+ function asRecord(value: unknown): Record<string, unknown> | null {
417
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
418
+ ? value as Record<string, unknown>
419
+ : null
420
+ }
421
+
422
+ function stringValue(value: unknown): string | null {
423
+ return typeof value === 'string' && value.trim() ? value.trim() : null
424
+ }
425
+
426
+ function deliveryRetryDelaySeconds(attempt: number): number {
427
+ return Math.min(300, Math.max(5, 2 ** Math.min(Math.max(attempt, 1), 8)))
428
+ }
429
+
430
+ function defaultResultEnvelope(dispatch: RuntimeDispatch, answer: string): RuntimeResultEnvelope {
431
+ return {
432
+ schema: 'agora.runtime-result/v1',
433
+ answer,
434
+ claims: [],
435
+ evidence: [],
436
+ environment: {
437
+ runtime_provider: 'dsh',
438
+ agent_ref: dispatch.runtime_target_ref.split(':').at(-1) ?? null,
439
+ workspace_alias: dispatch.workspace_alias ?? null,
440
+ },
441
+ }
442
+ }
package/src/service.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { AgoraClient } from './agora-client.js'
2
+ import { executeAgoraCommand } from './command.js'
3
+ import type {
4
+ AgoraCommandResult,
5
+ AgoraHealth,
6
+ AgoraRequestContext,
7
+ AgoraTask,
8
+ AgoraTaskStatus,
9
+ AgentScorecard,
10
+ CoordinationRun,
11
+ CoordinationRunStatus,
12
+ CreateCoordinationRunInput,
13
+ CreateAgoraTaskInput,
14
+ CreateRuntimeDispatchInput,
15
+ DispatchAgentInput,
16
+ DshAgoraImStatus,
17
+ DshAgoraServiceApi,
18
+ DshAgoraSnapshot,
19
+ DshAgoraNodeStatus,
20
+ RuntimeDispatch,
21
+ RuntimeDispatchProgress,
22
+ RuntimeNode,
23
+ RuntimeSessionBinding,
24
+ RuntimeTarget,
25
+ } from './contracts.js'
26
+ import type { DshAgoraExtensionManifestV1, DshAgoraExtensionV1 } from './extension-sdk.js'
27
+ import { DshAgoraExtensionRegistry } from './extension-sdk.js'
28
+ import type { DshImBridgeV1 } from './im-bridge-v1.js'
29
+ import { DshAgoraCommandAdapter, type DshAgoraCommandEventResultV1, type DshAgoraCommandEventV1 } from './command-adapter.js'
30
+
31
+ export interface DshAgoraServiceOptions {
32
+ readonly client: AgoraClient
33
+ readonly commandName: string
34
+ readonly defaultCreator: string
35
+ readonly registry?: DshAgoraExtensionRegistry
36
+ readonly imBridge?: DshImBridgeV1 | null
37
+ readonly nodeId?: string
38
+ }
39
+
40
+ export class DshAgoraService implements DshAgoraServiceApi {
41
+ readonly registry: DshAgoraExtensionRegistry
42
+ readonly commandAdapter: DshAgoraCommandAdapter
43
+ private imStatus: DshAgoraImStatus = {
44
+ state: 'unavailable',
45
+ reason: 'dsh-im command gateway discovery has not run',
46
+ }
47
+ private imBridgeStatus: DshAgoraImStatus = {
48
+ state: 'unavailable',
49
+ reason: 'dsh-im bridge discovery has not run',
50
+ }
51
+ private imBridge: DshImBridgeV1 | null
52
+ private nodeStatus: DshAgoraNodeStatus
53
+
54
+ constructor(private readonly options: DshAgoraServiceOptions) {
55
+ this.registry = options.registry ?? new DshAgoraExtensionRegistry()
56
+ this.imBridge = options.imBridge ?? null
57
+ this.nodeStatus = { state: 'disabled', nodeId: options.nodeId ?? 'unconfigured' }
58
+ this.commandAdapter = new DshAgoraCommandAdapter({
59
+ execute: (input, context, signal) => this.executeCommand(input, context, signal),
60
+ bridge: () => this.imBridge,
61
+ })
62
+ }
63
+
64
+ get serverUrl(): string {
65
+ return this.options.client.serverUrl
66
+ }
67
+
68
+ health(signal?: AbortSignal): Promise<AgoraHealth> {
69
+ return this.options.client.health(signal)
70
+ }
71
+
72
+ listTasks(state?: string, projectId?: string, signal?: AbortSignal): Promise<AgoraTask[]> {
73
+ return this.options.client.listTasks(state, projectId, signal)
74
+ }
75
+
76
+ getTask(taskId: string, signal?: AbortSignal): Promise<AgoraTask> {
77
+ return this.options.client.getTask(taskId, signal)
78
+ }
79
+
80
+ taskStatus(taskId: string, signal?: AbortSignal): Promise<AgoraTaskStatus> {
81
+ return this.options.client.taskStatus(taskId, signal)
82
+ }
83
+
84
+ createTask(input: CreateAgoraTaskInput, signal?: AbortSignal): Promise<AgoraTask> {
85
+ return this.options.client.createTask({
86
+ ...input,
87
+ creator: input.creator?.trim() || this.options.defaultCreator,
88
+ }, signal)
89
+ }
90
+
91
+ listRuntimeNodes(signal?: AbortSignal): Promise<RuntimeNode[]> {
92
+ return this.options.client.listRuntimeNodes(signal)
93
+ }
94
+
95
+ listRuntimeTargets(signal?: AbortSignal): Promise<RuntimeTarget[]> {
96
+ return this.options.client.listRuntimeTargets(signal)
97
+ }
98
+
99
+ createRuntimeDispatch(nodeId: string, input: CreateRuntimeDispatchInput, signal?: AbortSignal): Promise<RuntimeDispatch> {
100
+ return this.options.client.createRuntimeDispatch(nodeId, input, signal)
101
+ }
102
+
103
+ getRuntimeDispatch(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatch> {
104
+ return this.options.client.getRuntimeDispatch(dispatchId, signal)
105
+ }
106
+
107
+ listRuntimeDispatchProgress(dispatchId: string, signal?: AbortSignal): Promise<RuntimeDispatchProgress[]> {
108
+ return this.options.client.listRuntimeDispatchProgress(dispatchId, signal)
109
+ }
110
+
111
+ createCoordinationRun(input: CreateCoordinationRunInput, signal?: AbortSignal): Promise<CoordinationRun> {
112
+ return this.options.client.createCoordinationRun(input, signal)
113
+ }
114
+
115
+ getCoordinationRun(runId: string, signal?: AbortSignal): Promise<CoordinationRun> {
116
+ return this.options.client.getCoordinationRun(runId, signal)
117
+ }
118
+
119
+ listCoordinationRuns(status?: CoordinationRunStatus, signal?: AbortSignal): Promise<CoordinationRun[]> {
120
+ return this.options.client.listCoordinationRuns(status, signal)
121
+ }
122
+
123
+ listAgentScorecards(taskType?: string, signal?: AbortSignal): Promise<AgentScorecard[]> {
124
+ return this.options.client.listAgentScorecards(taskType, signal)
125
+ }
126
+
127
+ async dispatchAgent(input: DispatchAgentInput, signal?: AbortSignal): Promise<RuntimeDispatch> {
128
+ const { nodeId } = parseDshRuntimeTarget(input.runtime_target_ref)
129
+ let metadata: Readonly<Record<string, unknown>> | null = input.metadata ?? null
130
+ if (input.source_session_id && input.presentation_mode !== 'silent' && this.imBridge) {
131
+ const route = await this.imBridge.resolveSession(input.source_session_id)
132
+ if (route) {
133
+ metadata = {
134
+ ...(metadata ?? {}),
135
+ source_session_id: input.source_session_id,
136
+ presentation_target: {
137
+ mode: input.presentation_mode ?? 'destination_bot',
138
+ provider: route.provider,
139
+ conversation_ref: route.conversation_ref,
140
+ thread_ref: route.thread_ref,
141
+ reply_to_message_ref: route.reply_to_message_ref,
142
+ source_bot_ref: route.bot_ref,
143
+ },
144
+ }
145
+ }
146
+ }
147
+ const dispatch = await this.options.client.createRuntimeDispatch(nodeId, {
148
+ runtime_target_ref: input.runtime_target_ref,
149
+ prompt: input.prompt,
150
+ idempotency_key: input.idempotency_key,
151
+ metadata,
152
+ ...(input.task_id === undefined ? {} : { task_id: input.task_id }),
153
+ ...(input.participant_binding_id === undefined ? {} : { participant_binding_id: input.participant_binding_id }),
154
+ ...(input.session_id === undefined ? {} : { session_id: input.session_id }),
155
+ ...(input.workspace_alias === undefined ? {} : { workspace_alias: input.workspace_alias }),
156
+ ...(input.agent_preset === undefined ? {} : { agent_preset: input.agent_preset }),
157
+ }, signal)
158
+ const waitMs = normalizeWaitTimeout(input.wait_timeout_ms)
159
+ if (waitMs === 0 || isTerminal(dispatch)) return dispatch
160
+ const timeout = AbortSignal.timeout(waitMs)
161
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout])
162
+ let current = dispatch
163
+ while (!isTerminal(current)) {
164
+ await sleep(1_000, combined)
165
+ current = await this.options.client.getRuntimeDispatch(current.id, combined)
166
+ }
167
+ return current
168
+ }
169
+
170
+ bindRuntimeSession(
171
+ taskId: string,
172
+ participantBindingId: string,
173
+ sessionId: string,
174
+ agentRef?: string,
175
+ signal?: AbortSignal,
176
+ ): Promise<RuntimeSessionBinding> {
177
+ return this.options.client.bindRuntimeSession(taskId, participantBindingId, sessionId, agentRef, signal)
178
+ }
179
+
180
+ registerExtension(extension: DshAgoraExtensionV1, manifest?: DshAgoraExtensionManifestV1, packageBytes?: Uint8Array): () => void {
181
+ return this.registry.registerExtension(extension, manifest, packageBytes)
182
+ }
183
+
184
+ listExtensions(): readonly DshAgoraExtensionV1[] {
185
+ return this.registry.listExtensions()
186
+ }
187
+
188
+ executeCommand(rawInput: string, context: AgoraRequestContext = {}, signal?: AbortSignal): Promise<AgoraCommandResult> {
189
+ return executeAgoraCommand(this, rawInput, context, signal)
190
+ }
191
+
192
+ executeCommandEvent(event: DshAgoraCommandEventV1, signal?: AbortSignal): Promise<DshAgoraCommandEventResultV1> {
193
+ return this.commandAdapter.ingest(event, signal)
194
+ }
195
+
196
+ snapshot(): DshAgoraSnapshot {
197
+ return {
198
+ serverUrl: this.serverUrl,
199
+ command: `/${this.options.commandName}`,
200
+ im: this.imStatus,
201
+ imBridge: this.imBridgeStatus,
202
+ commandAdapter: { state: 'ready', protocol: this.commandAdapter.protocol },
203
+ node: this.nodeStatus,
204
+ extensions: this.registry.listExtensions().map(extension => ({
205
+ id: extension.id,
206
+ protocol: extension.protocol,
207
+ kind: extension.kind,
208
+ capabilities: extension.capabilities,
209
+ })),
210
+ }
211
+ }
212
+
213
+ setImStatus(status: DshAgoraImStatus): void {
214
+ this.imStatus = status
215
+ }
216
+
217
+ setImBridgeStatus(status: DshAgoraImStatus): void {
218
+ this.imBridgeStatus = status
219
+ }
220
+
221
+ setImBridge(bridge: DshImBridgeV1 | null, status: DshAgoraImStatus): void {
222
+ this.imBridge = bridge
223
+ this.imBridgeStatus = status
224
+ }
225
+
226
+ setNodeStatus(status: DshAgoraNodeStatus): void {
227
+ this.nodeStatus = status
228
+ }
229
+ }
230
+
231
+ function parseDshRuntimeTarget(value: string): { nodeId: string; agentRef: string } {
232
+ const match = /^dsh:([^:]+):(.+)$/u.exec(value.trim())
233
+ if (!match) throw new TypeError('runtime_target_ref must use dsh:<node-id>:<agent-ref>')
234
+ return { nodeId: match[1]!, agentRef: match[2]! }
235
+ }
236
+
237
+ function normalizeWaitTimeout(value: number | undefined): number {
238
+ const timeout = value ?? 0
239
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 600_000) {
240
+ throw new TypeError('wait_timeout_ms must be an integer between 0 and 600000')
241
+ }
242
+ return timeout
243
+ }
244
+
245
+ function isTerminal(dispatch: RuntimeDispatch): boolean {
246
+ return dispatch.status === 'completed' || dispatch.status === 'failed' || dispatch.status === 'cancelled'
247
+ }
248
+
249
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
250
+ return new Promise((resolve, reject) => {
251
+ if (signal.aborted) return reject(signal.reason)
252
+ const timer = setTimeout(() => {
253
+ signal.removeEventListener('abort', onAbort)
254
+ resolve()
255
+ }, ms)
256
+ const onAbort = (): void => {
257
+ clearTimeout(timer)
258
+ reject(signal.reason)
259
+ }
260
+ signal.addEventListener('abort', onAbort, { once: true })
261
+ })
262
+ }