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.
- package/README.md +520 -0
- package/client/index.js +492 -0
- package/cordis.patch.yml +14 -0
- package/dsh.plugin.json +7 -0
- package/lib/agora-client.d.ts +73 -0
- package/lib/agora-client.d.ts.map +1 -0
- package/lib/agora-client.js +207 -0
- package/lib/agora-client.js.map +1 -0
- package/lib/client.js +492 -0
- package/lib/command-adapter.d.ts +44 -0
- package/lib/command-adapter.d.ts.map +1 -0
- package/lib/command-adapter.js +70 -0
- package/lib/command-adapter.js.map +1 -0
- package/lib/command.d.ts +53 -0
- package/lib/command.d.ts.map +1 -0
- package/lib/command.js +375 -0
- package/lib/command.js.map +1 -0
- package/lib/context-types.d.ts +51 -0
- package/lib/context-types.d.ts.map +1 -0
- package/lib/context-types.js +2 -0
- package/lib/context-types.js.map +1 -0
- package/lib/contracts.d.ts +402 -0
- package/lib/contracts.d.ts.map +1 -0
- package/lib/contracts.js +2 -0
- package/lib/contracts.js.map +1 -0
- package/lib/extension-sdk.d.ts +74 -0
- package/lib/extension-sdk.d.ts.map +1 -0
- package/lib/extension-sdk.js +160 -0
- package/lib/extension-sdk.js.map +1 -0
- package/lib/harness-runtime.d.ts +29 -0
- package/lib/harness-runtime.d.ts.map +1 -0
- package/lib/harness-runtime.js +422 -0
- package/lib/harness-runtime.js.map +1 -0
- package/lib/http-api.d.ts +10 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +282 -0
- package/lib/http-api.js.map +1 -0
- package/lib/im-bridge-v1.d.ts +37 -0
- package/lib/im-bridge-v1.d.ts.map +1 -0
- package/lib/im-bridge-v1.js +43 -0
- package/lib/im-bridge-v1.js.map +1 -0
- package/lib/im-gateway.d.ts +24 -0
- package/lib/im-gateway.d.ts.map +1 -0
- package/lib/im-gateway.js +53 -0
- package/lib/im-gateway.js.map +1 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +210 -0
- package/lib/index.js.map +1 -0
- package/lib/node-worker.d.ts +52 -0
- package/lib/node-worker.d.ts.map +1 -0
- package/lib/node-worker.js +372 -0
- package/lib/node-worker.js.map +1 -0
- package/lib/service.d.ts +51 -0
- package/lib/service.d.ts.map +1 -0
- package/lib/service.js +196 -0
- package/lib/service.js.map +1 -0
- package/lib/tool.d.ts +72 -0
- package/lib/tool.d.ts.map +1 -0
- package/lib/tool.js +206 -0
- package/lib/tool.js.map +1 -0
- package/package.json +74 -0
- package/patches/dsh-im/@xmanrui__dsh-im@2.1.0.patch +920 -0
- package/patches/dsh-im/@xmanrui__dsh-im@2.3.0.patch +984 -0
- package/patches/dsh-im/README.md +82 -0
- package/src/agora-client.ts +333 -0
- package/src/command-adapter.ts +106 -0
- package/src/command.ts +424 -0
- package/src/context-types.ts +54 -0
- package/src/contracts.ts +413 -0
- package/src/extension-sdk.ts +225 -0
- package/src/harness-runtime.ts +518 -0
- package/src/http-api.ts +269 -0
- package/src/im-bridge-v1.ts +73 -0
- package/src/im-gateway.ts +82 -0
- package/src/index.ts +260 -0
- package/src/node-worker.ts +442 -0
- package/src/service.ts +262 -0
- 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
|
+
}
|