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
package/src/http-api.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
|
+
import type { AgoraRequestContext, DshAgoraServiceApi } from './contracts.js'
|
|
4
|
+
import type { DshWebServer } from './context-types.js'
|
|
5
|
+
|
|
6
|
+
export const API_PREFIX = '/dsh-agora/api'
|
|
7
|
+
const MAX_BODY_BYTES = 1_048_576
|
|
8
|
+
|
|
9
|
+
export interface HttpApiOptions {
|
|
10
|
+
readonly accessToken?: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerHttpApi(webServer: DshWebServer, service: DshAgoraServiceApi, options: HttpApiOptions): () => void {
|
|
14
|
+
return webServer.register({
|
|
15
|
+
kind: 'prefix',
|
|
16
|
+
path: API_PREFIX,
|
|
17
|
+
handler: (request, response) => handleHttpRequest(request, response, service, options),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function handleHttpRequest(
|
|
22
|
+
request: IncomingMessage,
|
|
23
|
+
response: ServerResponse,
|
|
24
|
+
service: DshAgoraServiceApi,
|
|
25
|
+
options: HttpApiOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
if (!authorized(request, options.accessToken)) {
|
|
29
|
+
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (request.method !== 'POST') {
|
|
33
|
+
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'POST required' } })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
|
|
37
|
+
const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(API_PREFIX.length + 1) : ''
|
|
38
|
+
if (method === '' || method.includes('/')) throw new HttpApiError(404, 'not-found', 'unknown dsh-agora API method')
|
|
39
|
+
const payload = asRecord(await readJsonBody(request))
|
|
40
|
+
const signal = AbortSignal.timeout(300_000)
|
|
41
|
+
let value: unknown
|
|
42
|
+
switch (method) {
|
|
43
|
+
case 'snapshot': value = service.snapshot(); break
|
|
44
|
+
case 'health': value = await service.health(signal); break
|
|
45
|
+
case 'nodes': value = await service.listRuntimeNodes(signal); break
|
|
46
|
+
case 'agents': value = await service.listRuntimeTargets(signal); break
|
|
47
|
+
case 'tasks': value = await service.listTasks(optionalString(payload.state), optionalString(payload.projectId), signal); break
|
|
48
|
+
case 'task': value = await service.getTask(requiredString(payload.taskId, 'taskId'), signal); break
|
|
49
|
+
case 'status': value = await service.taskStatus(requiredString(payload.taskId, 'taskId'), signal); break
|
|
50
|
+
case 'dispatch-status': value = await service.getRuntimeDispatch(requiredString(payload.dispatchId, 'dispatchId'), signal); break
|
|
51
|
+
case 'dispatch-progress': value = await service.listRuntimeDispatchProgress(requiredString(payload.dispatchId, 'dispatchId'), signal); break
|
|
52
|
+
case 'coordination-runs': value = await service.listCoordinationRuns(
|
|
53
|
+
optionalCoordinationStatus(payload.status),
|
|
54
|
+
signal,
|
|
55
|
+
); break
|
|
56
|
+
case 'coordination-run': value = await service.getCoordinationRun(requiredString(payload.runId, 'runId'), signal); break
|
|
57
|
+
case 'scorecards': value = await service.listAgentScorecards(optionalString(payload.taskType), signal); break
|
|
58
|
+
case 'coordination-create': {
|
|
59
|
+
const mode = requiredCoordinationMode(payload.mode)
|
|
60
|
+
const taskId = optionalString(payload.taskId)
|
|
61
|
+
const taskType = optionalString(payload.taskType)
|
|
62
|
+
const verifierTargetRef = optionalString(payload.verifierTargetRef)
|
|
63
|
+
value = await service.createCoordinationRun({
|
|
64
|
+
prompt: requiredString(payload.prompt, 'prompt'),
|
|
65
|
+
mode,
|
|
66
|
+
candidates: requiredStringArray(payload.runtimeTargetRefs, 'runtimeTargetRefs').map(runtime_target_ref => ({ runtime_target_ref })),
|
|
67
|
+
idempotency_key: requiredString(payload.idempotencyKey, 'idempotencyKey'),
|
|
68
|
+
...(taskId === undefined ? {} : { task_id: taskId }),
|
|
69
|
+
...(taskType === undefined ? {} : { task_type: taskType }),
|
|
70
|
+
...(verifierTargetRef === undefined ? {} : { verifier_target_ref: verifierTargetRef }),
|
|
71
|
+
...(payload.budget === undefined ? {} : { budget: coordinationBudget(payload.budget) }),
|
|
72
|
+
}, signal)
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
case 'dispatch': {
|
|
76
|
+
const taskId = optionalString(payload.taskId)
|
|
77
|
+
const participantBindingId = optionalString(payload.participantBindingId)
|
|
78
|
+
const sessionId = optionalString(payload.sessionId)
|
|
79
|
+
const workspaceAlias = optionalString(payload.workspaceAlias)
|
|
80
|
+
const agentPreset = optionalString(payload.agentPreset)
|
|
81
|
+
const sourceSessionId = optionalString(payload.sourceSessionId)
|
|
82
|
+
const presentationMode = optionalString(payload.presentationMode)
|
|
83
|
+
const waitTimeoutMs = optionalInteger(payload.waitTimeoutMs, 'waitTimeoutMs', 0, 600_000)
|
|
84
|
+
if (presentationMode !== undefined && !['source_bot', 'destination_bot', 'silent'].includes(presentationMode)) {
|
|
85
|
+
throw new HttpApiError(400, 'bad-request', 'presentationMode is invalid')
|
|
86
|
+
}
|
|
87
|
+
value = await service.dispatchAgent({
|
|
88
|
+
runtime_target_ref: requiredString(payload.runtimeTargetRef, 'runtimeTargetRef'),
|
|
89
|
+
prompt: requiredString(payload.prompt, 'prompt'),
|
|
90
|
+
idempotency_key: requiredString(payload.idempotencyKey, 'idempotencyKey'),
|
|
91
|
+
...(taskId === undefined ? {} : { task_id: taskId }),
|
|
92
|
+
...(participantBindingId === undefined ? {} : { participant_binding_id: participantBindingId }),
|
|
93
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
94
|
+
...(workspaceAlias === undefined ? {} : { workspace_alias: workspaceAlias }),
|
|
95
|
+
...(agentPreset === undefined ? {} : { agent_preset: agentPreset }),
|
|
96
|
+
...(sourceSessionId === undefined ? {} : { source_session_id: sourceSessionId }),
|
|
97
|
+
...(waitTimeoutMs === undefined ? {} : { wait_timeout_ms: waitTimeoutMs }),
|
|
98
|
+
...(presentationMode === undefined ? {} : {
|
|
99
|
+
presentation_mode: presentationMode as 'source_bot' | 'destination_bot' | 'silent',
|
|
100
|
+
}),
|
|
101
|
+
}, signal)
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case 'attach-session': value = await service.bindRuntimeSession(
|
|
105
|
+
requiredString(payload.taskId, 'taskId'),
|
|
106
|
+
requiredString(payload.participantBindingId, 'participantBindingId'),
|
|
107
|
+
requiredString(payload.sessionId, 'sessionId'),
|
|
108
|
+
optionalString(payload.runtimeTargetRef),
|
|
109
|
+
signal,
|
|
110
|
+
); break
|
|
111
|
+
case 'create': {
|
|
112
|
+
const type = optionalString(payload.type)
|
|
113
|
+
const creator = optionalString(payload.creator)
|
|
114
|
+
const description = optionalString(payload.description)
|
|
115
|
+
const projectId = optionalString(payload.projectId)
|
|
116
|
+
value = await service.createTask({
|
|
117
|
+
title: requiredString(payload.title, 'title'),
|
|
118
|
+
...(type === undefined ? {} : { type }),
|
|
119
|
+
...(creator === undefined ? {} : { creator }),
|
|
120
|
+
...(description === undefined ? {} : { description }),
|
|
121
|
+
...(projectId === undefined ? {} : { projectId }),
|
|
122
|
+
}, signal)
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
case 'command': value = await service.executeCommand(
|
|
126
|
+
optionalString(payload.input) ?? '',
|
|
127
|
+
requestContext(payload.context),
|
|
128
|
+
signal,
|
|
129
|
+
); break
|
|
130
|
+
case 'command-event': value = await service.executeCommandEvent(payload as unknown as import('./command-adapter.js').DshAgoraCommandEventV1, signal); break
|
|
131
|
+
default: throw new HttpApiError(404, 'not-found', `unknown dsh-agora API method "${method}"`)
|
|
132
|
+
}
|
|
133
|
+
writeJson(response, 200, { ok: true, value })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof HttpApiError) {
|
|
136
|
+
writeJson(response, error.status, { ok: false, error: { code: error.code, message: error.message } })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
writeJson(response, 502, { ok: false, error: { code: 'upstream-error', message: error instanceof Error ? error.message : String(error) } })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
class HttpApiError extends Error {
|
|
144
|
+
constructor(readonly status: number, readonly code: string, message: string) {
|
|
145
|
+
super(message)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function authorized(request: IncomingMessage, configuredToken: string | undefined): boolean {
|
|
150
|
+
if (isLoopback(request.socket.remoteAddress)) return true
|
|
151
|
+
const token = configuredToken?.trim()
|
|
152
|
+
if (!token) return false
|
|
153
|
+
const authorization = request.headers.authorization
|
|
154
|
+
if (!authorization?.startsWith('Bearer ')) return false
|
|
155
|
+
const supplied = authorization.slice(7)
|
|
156
|
+
const expectedBytes = Buffer.from(token)
|
|
157
|
+
const suppliedBytes = Buffer.from(supplied)
|
|
158
|
+
return expectedBytes.length === suppliedBytes.length && timingSafeEqual(expectedBytes, suppliedBytes)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isLoopback(address: string | undefined): boolean {
|
|
162
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
|
166
|
+
const chunks: Buffer[] = []
|
|
167
|
+
let length = 0
|
|
168
|
+
for await (const chunk of request) {
|
|
169
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
170
|
+
length += bytes.length
|
|
171
|
+
if (length > MAX_BODY_BYTES) throw new HttpApiError(413, 'body-too-large', 'request body exceeds 1 MiB')
|
|
172
|
+
chunks.push(bytes)
|
|
173
|
+
}
|
|
174
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
175
|
+
if (text === '') return {}
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(text) as unknown
|
|
178
|
+
} catch {
|
|
179
|
+
throw new HttpApiError(400, 'bad-json', 'request body must be valid JSON')
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
184
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new HttpApiError(400, 'bad-request', 'request body must be an object')
|
|
185
|
+
return value as Record<string, unknown>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function optionalString(value: unknown): string | undefined {
|
|
189
|
+
if (value === undefined || value === null) return undefined
|
|
190
|
+
if (typeof value !== 'string') throw new HttpApiError(400, 'bad-request', 'expected a string field')
|
|
191
|
+
const normalized = value.trim()
|
|
192
|
+
return normalized === '' ? undefined : normalized
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function requiredString(value: unknown, field: string): string {
|
|
196
|
+
const normalized = optionalString(value)
|
|
197
|
+
if (normalized === undefined) throw new HttpApiError(400, 'bad-request', `${field} is required`)
|
|
198
|
+
return normalized
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function optionalInteger(value: unknown, field: string, minimum: number, maximum: number): number | undefined {
|
|
202
|
+
if (value === undefined || value === null) return undefined
|
|
203
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
204
|
+
throw new HttpApiError(400, 'bad-request', `${field} must be an integer between ${minimum} and ${maximum}`)
|
|
205
|
+
}
|
|
206
|
+
return value as number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function requiredStringArray(value: unknown, field: string): string[] {
|
|
210
|
+
if (!Array.isArray(value) || value.length === 0) throw new HttpApiError(400, 'bad-request', `${field} must be a non-empty string array`)
|
|
211
|
+
return value.map(item => requiredString(item, field))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function requiredCoordinationMode(value: unknown): 'single' | 'fanout' | 'review' | 'debate' | 'council' {
|
|
215
|
+
const mode = requiredString(value, 'mode')
|
|
216
|
+
if (mode === 'single' || mode === 'fanout' || mode === 'review' || mode === 'debate' || mode === 'council') return mode
|
|
217
|
+
throw new HttpApiError(400, 'bad-request', 'mode must be single, fanout, review, debate, or council')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function optionalCoordinationStatus(value: unknown): import('./contracts.js').CoordinationRunStatus | undefined {
|
|
221
|
+
const status = optionalString(value)
|
|
222
|
+
if (status === undefined) return undefined
|
|
223
|
+
if (status === 'running' || status === 'verifying' || status === 'completed' || status === 'partial'
|
|
224
|
+
|| status === 'failed' || status === 'cancelled' || status === 'budget_exhausted') return status
|
|
225
|
+
throw new HttpApiError(400, 'bad-request', 'coordination status is invalid')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function coordinationBudget(value: unknown): Partial<import('./contracts.js').CoordinationBudget> {
|
|
229
|
+
const record = asRecord(value)
|
|
230
|
+
const fields = [
|
|
231
|
+
['max_agents', 1, 32],
|
|
232
|
+
['max_dispatches', 1, 64],
|
|
233
|
+
['max_wall_clock_seconds', 15, 86_400],
|
|
234
|
+
['max_tokens', 1, Number.MAX_SAFE_INTEGER],
|
|
235
|
+
['max_tool_calls', 1, Number.MAX_SAFE_INTEGER],
|
|
236
|
+
] as const
|
|
237
|
+
const budget: Record<string, number> = {}
|
|
238
|
+
for (const [field, minimum, maximum] of fields) {
|
|
239
|
+
const item = optionalInteger(record[field], field, minimum, maximum)
|
|
240
|
+
if (item !== undefined) budget[field] = item
|
|
241
|
+
}
|
|
242
|
+
if (record.max_cost_usd !== undefined) {
|
|
243
|
+
if (typeof record.max_cost_usd !== 'number' || record.max_cost_usd <= 0) throw new HttpApiError(400, 'bad-request', 'max_cost_usd must be positive')
|
|
244
|
+
budget.max_cost_usd = record.max_cost_usd
|
|
245
|
+
}
|
|
246
|
+
return budget
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function requestContext(value: unknown): AgoraRequestContext {
|
|
250
|
+
if (value === undefined) return {}
|
|
251
|
+
const record = asRecord(value)
|
|
252
|
+
const actorId = optionalString(record.actorId)
|
|
253
|
+
const provider = optionalString(record.provider)
|
|
254
|
+
const conversationRef = optionalString(record.conversationRef)
|
|
255
|
+
const threadRef = optionalString(record.threadRef)
|
|
256
|
+
return {
|
|
257
|
+
...(actorId === undefined ? {} : { actorId }),
|
|
258
|
+
...(provider === undefined ? {} : { provider }),
|
|
259
|
+
...(conversationRef === undefined ? {} : { conversationRef }),
|
|
260
|
+
...(threadRef === undefined ? {} : { threadRef }),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
265
|
+
response.statusCode = status
|
|
266
|
+
response.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
267
|
+
response.setHeader('Cache-Control', 'no-store')
|
|
268
|
+
response.end(JSON.stringify(body))
|
|
269
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { DshAgoraImStatus, RuntimeNodeBot } from './contracts.js'
|
|
2
|
+
|
|
3
|
+
export const DSH_IM_BRIDGE_PROTOCOL = 'dsh-im.bridge/v1' as const
|
|
4
|
+
|
|
5
|
+
export interface DshImSessionRouteV1 {
|
|
6
|
+
readonly provider: string
|
|
7
|
+
readonly bot_ref: string
|
|
8
|
+
readonly session_id: string
|
|
9
|
+
readonly actor_ref: string
|
|
10
|
+
readonly conversation_ref: string
|
|
11
|
+
readonly thread_ref: string | null
|
|
12
|
+
readonly reply_to_message_ref: string | null
|
|
13
|
+
readonly updated_at: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DshImSendRequestV1 {
|
|
17
|
+
readonly provider: string
|
|
18
|
+
readonly bot_ref?: string | null
|
|
19
|
+
readonly conversation_ref: string
|
|
20
|
+
readonly thread_ref?: string | null
|
|
21
|
+
readonly reply_to_message_ref?: string | null
|
|
22
|
+
readonly text: string
|
|
23
|
+
readonly idempotency_key: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DshImBridgeV1 {
|
|
27
|
+
readonly protocol: typeof DSH_IM_BRIDGE_PROTOCOL
|
|
28
|
+
listBots(): readonly RuntimeNodeBot[] | Promise<readonly RuntimeNodeBot[]>
|
|
29
|
+
resolveSession(sessionId: string): DshImSessionRouteV1 | null | Promise<DshImSessionRouteV1 | null>
|
|
30
|
+
send(request: DshImSendRequestV1): Promise<{ readonly provider_message_refs: readonly string[] }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ImBridgeDiscovery {
|
|
34
|
+
readonly status: DshAgoraImStatus
|
|
35
|
+
readonly bridge: DshImBridgeV1 | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function discoverImBridge(ctx: { get?(name: string): unknown }, serviceNames: readonly string[]): ImBridgeDiscovery {
|
|
39
|
+
for (const serviceName of serviceNames) {
|
|
40
|
+
const candidate = safeGet(ctx, serviceName)
|
|
41
|
+
if (candidate === undefined) continue
|
|
42
|
+
if (!isBridge(candidate)) {
|
|
43
|
+
return {
|
|
44
|
+
status: {
|
|
45
|
+
state: 'incompatible', service: serviceName,
|
|
46
|
+
reason: `service does not implement ${DSH_IM_BRIDGE_PROTOCOL}`,
|
|
47
|
+
},
|
|
48
|
+
bridge: null,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
status: { state: 'connected', service: serviceName, protocol: candidate.protocol },
|
|
53
|
+
bridge: candidate,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
status: { state: 'unavailable', reason: `no ${DSH_IM_BRIDGE_PROTOCOL} provider is installed` },
|
|
58
|
+
bridge: null,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeGet(ctx: { get?(name: string): unknown }, name: string): unknown {
|
|
63
|
+
try { return ctx.get?.(name) } catch { return undefined }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isBridge(value: unknown): value is DshImBridgeV1 {
|
|
67
|
+
if (typeof value !== 'object' || value === null) return false
|
|
68
|
+
const bridge = value as Partial<DshImBridgeV1>
|
|
69
|
+
return bridge.protocol === DSH_IM_BRIDGE_PROTOCOL
|
|
70
|
+
&& typeof bridge.listBots === 'function'
|
|
71
|
+
&& typeof bridge.resolveSession === 'function'
|
|
72
|
+
&& typeof bridge.send === 'function'
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { AgoraCommandResult, AgoraRequestContext, DshAgoraImStatus } from './contracts.js'
|
|
2
|
+
|
|
3
|
+
export const DSH_IM_COMMAND_GATEWAY_PROTOCOL = 'dsh-im.command-gateway/v1'
|
|
4
|
+
|
|
5
|
+
export interface DshImCommandInvocationV1 extends AgoraRequestContext {
|
|
6
|
+
readonly rawInput: string
|
|
7
|
+
readonly signal?: AbortSignal
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DshImCommandDefinitionV1 {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly description: string
|
|
13
|
+
execute(invocation: DshImCommandInvocationV1): Promise<AgoraCommandResult>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DshImCommandGatewayV1 {
|
|
17
|
+
readonly protocol: typeof DSH_IM_COMMAND_GATEWAY_PROTOCOL
|
|
18
|
+
registerCommand(definition: DshImCommandDefinitionV1): () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ImGatewayContext {
|
|
22
|
+
get?(name: string): unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ImRegistration {
|
|
26
|
+
readonly status: DshAgoraImStatus
|
|
27
|
+
readonly dispose?: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerImCommand(
|
|
31
|
+
ctx: ImGatewayContext,
|
|
32
|
+
serviceNames: readonly string[],
|
|
33
|
+
definition: DshImCommandDefinitionV1,
|
|
34
|
+
): ImRegistration {
|
|
35
|
+
for (const serviceName of serviceNames) {
|
|
36
|
+
const candidate = safeGet(ctx, serviceName)
|
|
37
|
+
if (candidate === undefined) continue
|
|
38
|
+
if (!isGateway(candidate)) {
|
|
39
|
+
return {
|
|
40
|
+
status: {
|
|
41
|
+
state: 'incompatible',
|
|
42
|
+
service: serviceName,
|
|
43
|
+
reason: `service does not implement ${DSH_IM_COMMAND_GATEWAY_PROTOCOL}`,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return {
|
|
49
|
+
status: { state: 'connected', service: serviceName, protocol: candidate.protocol },
|
|
50
|
+
dispose: candidate.registerCommand(definition),
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
status: {
|
|
55
|
+
state: 'incompatible',
|
|
56
|
+
service: serviceName,
|
|
57
|
+
reason: `gateway rejected command registration: ${error instanceof Error ? error.message : String(error)}`,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: {
|
|
64
|
+
state: 'unavailable',
|
|
65
|
+
reason: `no ${DSH_IM_COMMAND_GATEWAY_PROTOCOL} provider is installed`,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeGet(ctx: ImGatewayContext, serviceName: string): unknown {
|
|
71
|
+
try {
|
|
72
|
+
return ctx.get?.(serviceName)
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isGateway(value: unknown): value is DshImCommandGatewayV1 {
|
|
79
|
+
if (typeof value !== 'object' || value === null) return false
|
|
80
|
+
const candidate = value as Partial<DshImCommandGatewayV1>
|
|
81
|
+
return candidate.protocol === DSH_IM_COMMAND_GATEWAY_PROTOCOL && typeof candidate.registerCommand === 'function'
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { hostname } from 'node:os'
|
|
3
|
+
import { AgoraClient } from './agora-client.js'
|
|
4
|
+
import type { DshAgoraContext, DshWebServer } from './context-types.js'
|
|
5
|
+
import { registerHttpApi } from './http-api.js'
|
|
6
|
+
import { registerImCommand } from './im-gateway.js'
|
|
7
|
+
import { discoverImBridge } from './im-bridge-v1.js'
|
|
8
|
+
import { DshAgoraService } from './service.js'
|
|
9
|
+
import { createAgoraTool } from './tool.js'
|
|
10
|
+
import { DshAgoraExtensionRegistry, DSH_AGORA_EXTENSION_PROTOCOL } from './extension-sdk.js'
|
|
11
|
+
import { HarnessRuntimeAdapter, type ConfiguredDshAgent } from './harness-runtime.js'
|
|
12
|
+
import { RuntimeNodeWorker } from './node-worker.js'
|
|
13
|
+
|
|
14
|
+
export { AgoraApiError, AgoraClient } from './agora-client.js'
|
|
15
|
+
export { AgoraCommandParseError, executeAgoraCommand, parseAgoraCommand } from './command.js'
|
|
16
|
+
export * from './contracts.js'
|
|
17
|
+
export { API_PREFIX, handleHttpRequest, registerHttpApi } from './http-api.js'
|
|
18
|
+
export * from './im-gateway.js'
|
|
19
|
+
export * from './im-bridge-v1.js'
|
|
20
|
+
export * from './command-adapter.js'
|
|
21
|
+
export * from './extension-sdk.js'
|
|
22
|
+
export * from './harness-runtime.js'
|
|
23
|
+
export { RuntimeNodeWorker } from './node-worker.js'
|
|
24
|
+
export { DshAgoraService } from './service.js'
|
|
25
|
+
export * from './tool.js'
|
|
26
|
+
|
|
27
|
+
export const name = 'dsh-agora'
|
|
28
|
+
// webServer is required for both the local Harness RPC adapter and the runtime
|
|
29
|
+
// node worker. Declaring it here also makes Cordis delay apply() until the web
|
|
30
|
+
// host is initialized instead of silently starting in command-only mode.
|
|
31
|
+
export const inject = ['commands', 'tools', 'webServer']
|
|
32
|
+
const PLUGIN_VERSION = '0.6.0'
|
|
33
|
+
|
|
34
|
+
export interface Config {
|
|
35
|
+
readonly serverUrl?: string
|
|
36
|
+
readonly apiToken?: string
|
|
37
|
+
readonly nodeApiToken?: string
|
|
38
|
+
readonly requestTimeoutMs?: number
|
|
39
|
+
readonly defaultCreator?: string
|
|
40
|
+
readonly commandName?: string
|
|
41
|
+
readonly apiAccessToken?: string
|
|
42
|
+
readonly imGatewayServices?: readonly string[]
|
|
43
|
+
readonly imBridgeServices?: readonly string[]
|
|
44
|
+
readonly nodeEnabled?: boolean
|
|
45
|
+
readonly nodeId?: string
|
|
46
|
+
readonly heartbeatIntervalMs?: number
|
|
47
|
+
readonly dispatchPollIntervalMs?: number
|
|
48
|
+
readonly nodeLeaseSeconds?: number
|
|
49
|
+
readonly dispatchLeaseSeconds?: number
|
|
50
|
+
readonly dispatchRenewIntervalMs?: number
|
|
51
|
+
readonly deliveryPollIntervalMs?: number
|
|
52
|
+
readonly deliveryLeaseSeconds?: number
|
|
53
|
+
readonly maxConcurrent?: number
|
|
54
|
+
readonly runtimeReplyTimeoutMs?: number
|
|
55
|
+
readonly runtimeAgents?: readonly ConfiguredDshAgent[]
|
|
56
|
+
readonly nodeMetadata?: Readonly<Record<string, unknown>>
|
|
57
|
+
readonly extensionSecurity?: {
|
|
58
|
+
readonly requireSignedThirdParty?: boolean
|
|
59
|
+
readonly trustedPublicKeys?: Readonly<Record<string, string>>
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function apply(ctx: DshAgoraContext, config: Config = {}): void {
|
|
64
|
+
const commandName = normalizeCommandName(config.commandName ?? 'agora')
|
|
65
|
+
const nodeId = normalizeNodeId(config.nodeId ?? process.env.DSH_AGORA_NODE_ID ?? hostname())
|
|
66
|
+
const webServer = safeGet(ctx, 'webServer')
|
|
67
|
+
const imBridgeDiscovery = discoverImBridge(
|
|
68
|
+
ctx,
|
|
69
|
+
unique(config.imBridgeServices ?? ['dshImBridge', 'dshImAgoraBridge']),
|
|
70
|
+
)
|
|
71
|
+
let activeImBridge = imBridgeDiscovery.bridge
|
|
72
|
+
let worker: RuntimeNodeWorker | null = null
|
|
73
|
+
const registry = new DshAgoraExtensionRegistry({
|
|
74
|
+
...(config.extensionSecurity?.requireSignedThirdParty === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { requireSignedThirdParty: config.extensionSecurity.requireSignedThirdParty }),
|
|
77
|
+
...(config.extensionSecurity?.trustedPublicKeys === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { trustedPublicKeys: config.extensionSecurity.trustedPublicKeys }),
|
|
80
|
+
builtInExtensionIds: ['dsh-runtime'],
|
|
81
|
+
})
|
|
82
|
+
const client = new AgoraClient({
|
|
83
|
+
serverUrl: config.serverUrl ?? process.env.AGORA_SERVER_URL ?? 'http://127.0.0.1:18008',
|
|
84
|
+
apiToken: config.apiToken ?? process.env.AGORA_API_TOKEN,
|
|
85
|
+
timeoutMs: config.requestTimeoutMs,
|
|
86
|
+
})
|
|
87
|
+
const workerClient = new AgoraClient({
|
|
88
|
+
serverUrl: config.serverUrl ?? process.env.AGORA_SERVER_URL ?? 'http://127.0.0.1:18008',
|
|
89
|
+
apiToken: config.nodeApiToken ?? process.env.AGORA_NODE_API_TOKEN ?? config.apiToken ?? process.env.AGORA_API_TOKEN,
|
|
90
|
+
timeoutMs: config.requestTimeoutMs,
|
|
91
|
+
})
|
|
92
|
+
const service = new DshAgoraService({
|
|
93
|
+
client,
|
|
94
|
+
commandName,
|
|
95
|
+
defaultCreator: config.defaultCreator?.trim() || 'dsh',
|
|
96
|
+
registry,
|
|
97
|
+
imBridge: imBridgeDiscovery.bridge,
|
|
98
|
+
nodeId,
|
|
99
|
+
})
|
|
100
|
+
service.setImBridgeStatus(imBridgeDiscovery.status)
|
|
101
|
+
|
|
102
|
+
const refreshImBridge = (): void => {
|
|
103
|
+
const discovery = discoverImBridge(
|
|
104
|
+
ctx,
|
|
105
|
+
unique(config.imBridgeServices ?? ['dshImBridge', 'dshImAgoraBridge']),
|
|
106
|
+
)
|
|
107
|
+
// A bridge discovered by the optional Cordis injection below is scoped to
|
|
108
|
+
// its child context and is intentionally not visible from this parent.
|
|
109
|
+
// Do not let the legacy poller overwrite that live injected bridge.
|
|
110
|
+
if (discovery.bridge === null && activeImBridge !== null) return
|
|
111
|
+
activeImBridge = discovery.bridge
|
|
112
|
+
service.setImBridge(discovery.bridge, discovery.status)
|
|
113
|
+
worker?.setImBridge(discovery.bridge)
|
|
114
|
+
}
|
|
115
|
+
const imBridgeTimer = setInterval(refreshImBridge, 5_000)
|
|
116
|
+
imBridgeTimer.unref?.()
|
|
117
|
+
own(ctx, () => clearInterval(imBridgeTimer), 'dsh-agora: dsh-im bridge discovery')
|
|
118
|
+
|
|
119
|
+
// Optional dependency: this child fiber stays pending when dsh-im is absent,
|
|
120
|
+
// while the main dsh-agora plugin remains fully usable in headless mode. When
|
|
121
|
+
// dsh-im appears or reloads, Cordis activates this callback automatically.
|
|
122
|
+
ctx.inject?.(['dshImBridge'], bridgeCtx => {
|
|
123
|
+
const discovery = discoverImBridge(bridgeCtx, ['dshImBridge'])
|
|
124
|
+
if (discovery.bridge === null) return
|
|
125
|
+
activeImBridge = discovery.bridge
|
|
126
|
+
service.setImBridge(discovery.bridge, discovery.status)
|
|
127
|
+
worker?.setImBridge(discovery.bridge)
|
|
128
|
+
return () => {
|
|
129
|
+
if (activeImBridge !== discovery.bridge) return
|
|
130
|
+
activeImBridge = null
|
|
131
|
+
worker?.setImBridge(null)
|
|
132
|
+
service.setImBridge(null, {
|
|
133
|
+
state: 'unavailable',
|
|
134
|
+
reason: 'dsh-im.bridge/v1 provider was unloaded',
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (isWebServer(webServer) && Number.isInteger(webServer.port)) {
|
|
140
|
+
const runtime = new HarnessRuntimeAdapter({
|
|
141
|
+
baseUrl: `http://127.0.0.1:${webServer.port}`,
|
|
142
|
+
agents: config.runtimeAgents ?? [{ id: 'default', displayName: 'DeepSeek Harness', workspace: process.cwd() }],
|
|
143
|
+
...(config.runtimeReplyTimeoutMs === undefined ? {} : { replyTimeoutMs: config.runtimeReplyTimeoutMs }),
|
|
144
|
+
})
|
|
145
|
+
const unregisterRuntime = service.registerExtension({
|
|
146
|
+
protocol: DSH_AGORA_EXTENSION_PROTOCOL,
|
|
147
|
+
id: 'dsh-runtime',
|
|
148
|
+
kind: 'runtime',
|
|
149
|
+
capabilities: ['runtime.execute', 'session.create', 'session.resume', 'session.prompt', 'session.cancel'],
|
|
150
|
+
runtime,
|
|
151
|
+
})
|
|
152
|
+
own(ctx, unregisterRuntime, 'dsh-agora: built-in DSH runtime adapter')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const unregisterCommand = ctx.commands.register({
|
|
156
|
+
name: commandName,
|
|
157
|
+
description: 'create and inspect governed Agora tasks',
|
|
158
|
+
input: { hint: '[health|nodes|agents|list|show|status|dispatch-status|create|dashboard|im]' },
|
|
159
|
+
handler: invocation => service.executeCommand(
|
|
160
|
+
invocation.rawInput,
|
|
161
|
+
{ actorId: invocation.agent.id ?? invocation.agent.session?.id ?? 'dsh' },
|
|
162
|
+
invocation.signal,
|
|
163
|
+
),
|
|
164
|
+
})
|
|
165
|
+
// commands.register() is already owned by the current Cordis effect. Keeping
|
|
166
|
+
// the returned capability alive is enough; wrapping it would double-dispose.
|
|
167
|
+
void unregisterCommand
|
|
168
|
+
|
|
169
|
+
// Primary IM-independent path: dsh-im only delivers text into a DSH Session;
|
|
170
|
+
// the Agent uses this normal DSH tool and dsh-im relays the ordinary reply.
|
|
171
|
+
const unregisterTool = ctx.tools.register(createAgoraTool(service))
|
|
172
|
+
void unregisterTool
|
|
173
|
+
|
|
174
|
+
const imRegistration = registerImCommand(
|
|
175
|
+
ctx,
|
|
176
|
+
unique(config.imGatewayServices ?? ['dshImCommandGateway', 'dshImGateway']),
|
|
177
|
+
{
|
|
178
|
+
name: commandName,
|
|
179
|
+
description: 'create and inspect governed Agora tasks',
|
|
180
|
+
execute: invocation => service.executeCommand(invocation.rawInput, invocation, invocation.signal),
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
service.setImStatus(imRegistration.status)
|
|
184
|
+
if (imRegistration.dispose !== undefined) own(ctx, imRegistration.dispose, 'dsh-agora: dsh-im command gateway')
|
|
185
|
+
|
|
186
|
+
if (isWebServer(webServer)) {
|
|
187
|
+
own(ctx, registerHttpApi(webServer, service, {
|
|
188
|
+
accessToken: config.apiAccessToken ?? process.env.DSH_AGORA_API_TOKEN,
|
|
189
|
+
}), 'dsh-agora: host API')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (config.nodeEnabled !== false && isWebServer(webServer) && Number.isInteger(webServer.port)) {
|
|
193
|
+
worker = new RuntimeNodeWorker({
|
|
194
|
+
client: workerClient,
|
|
195
|
+
registry,
|
|
196
|
+
nodeId,
|
|
197
|
+
instanceId: randomUUID(),
|
|
198
|
+
pluginVersion: PLUGIN_VERSION,
|
|
199
|
+
...(config.heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs: config.heartbeatIntervalMs }),
|
|
200
|
+
...(config.dispatchPollIntervalMs === undefined ? {} : { dispatchPollIntervalMs: config.dispatchPollIntervalMs }),
|
|
201
|
+
...(config.nodeLeaseSeconds === undefined ? {} : { leaseSeconds: config.nodeLeaseSeconds }),
|
|
202
|
+
...(config.dispatchLeaseSeconds === undefined ? {} : { dispatchLeaseSeconds: config.dispatchLeaseSeconds }),
|
|
203
|
+
...(config.dispatchRenewIntervalMs === undefined ? {} : { dispatchRenewIntervalMs: config.dispatchRenewIntervalMs }),
|
|
204
|
+
...(config.deliveryPollIntervalMs === undefined ? {} : { deliveryPollIntervalMs: config.deliveryPollIntervalMs }),
|
|
205
|
+
...(config.deliveryLeaseSeconds === undefined ? {} : { deliveryLeaseSeconds: config.deliveryLeaseSeconds }),
|
|
206
|
+
...(config.maxConcurrent === undefined ? {} : { maxConcurrent: config.maxConcurrent }),
|
|
207
|
+
imBridge: activeImBridge,
|
|
208
|
+
...(config.nodeMetadata === undefined ? {} : { metadata: config.nodeMetadata }),
|
|
209
|
+
onStatus: status => service.setNodeStatus(status),
|
|
210
|
+
})
|
|
211
|
+
worker.start()
|
|
212
|
+
own(ctx, () => worker?.stop(), 'dsh-agora: runtime node worker')
|
|
213
|
+
} else {
|
|
214
|
+
service.setNodeStatus({ state: 'disabled', nodeId })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ctx.accessor?.('dshAgora', { get: () => service })
|
|
218
|
+
ctx.accessor?.('dshAgoraCommandAdapter', { get: () => service.commandAdapter })
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeNodeId(value: string): string {
|
|
222
|
+
const nodeId = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/gu, '-')
|
|
223
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(nodeId)) throw new TypeError('nodeId is invalid')
|
|
224
|
+
return nodeId
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeCommandName(value: string): string {
|
|
228
|
+
const name = value.trim().toLowerCase()
|
|
229
|
+
if (!/^[a-z][a-z0-9-]*$/u.test(name)) throw new TypeError('commandName must be lowercase letters, digits, or hyphens')
|
|
230
|
+
return name
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function unique(values: readonly string[]): string[] {
|
|
234
|
+
return [...new Set(values.map(value => value.trim()).filter(Boolean))]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function safeGet(ctx: DshAgoraContext, name: string): unknown {
|
|
238
|
+
try {
|
|
239
|
+
// Cordis accessors (such as the public dsh-im bridge) are resolved through
|
|
240
|
+
// the context proxy, while ctx.get() only reads provided service values.
|
|
241
|
+
const accessorValue = Reflect.get(ctx as object, name)
|
|
242
|
+
if (accessorValue !== undefined) return accessorValue
|
|
243
|
+
} catch {
|
|
244
|
+
// Unknown, non-injected proxy properties throw by design; fall through to
|
|
245
|
+
// the provider lookup for ordinary Cordis services.
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return ctx.get?.(name)
|
|
249
|
+
} catch {
|
|
250
|
+
return undefined
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isWebServer(value: unknown): value is DshWebServer {
|
|
255
|
+
return typeof value === 'object' && value !== null && typeof (value as Partial<DshWebServer>).register === 'function'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function own(ctx: DshAgoraContext, dispose: () => void, label: string): void {
|
|
259
|
+
if (ctx.effect !== undefined) ctx.effect(() => dispose, label)
|
|
260
|
+
}
|