@ai_mind/dsh-a2a 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +467 -2
- package/client.js +927 -0
- package/cordis.patch.yml +26 -0
- package/icon.svg +15 -0
- package/index.js +59 -0
- package/lib/auth.js +196 -0
- package/lib/bridge.js +518 -0
- package/lib/card.js +214 -0
- package/lib/client-discovery.js +58 -0
- package/lib/config.js +576 -0
- package/lib/executor.js +357 -0
- package/lib/files.js +52 -0
- package/lib/limits.js +133 -0
- package/lib/panel.js +391 -0
- package/lib/prompt.js +161 -0
- package/lib/server.js +656 -0
- package/lib/store.js +288 -0
- package/lib/util.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -4
package/lib/panel.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-panel configuration API for the A2A settings page.
|
|
3
|
+
*
|
|
4
|
+
* The browser panel is authenticated by the DSH connection (the same signed
|
|
5
|
+
* credential the GUI uses), never by the A2A token: an operator who can open
|
|
6
|
+
* Settings may edit the plugin, while a remote A2A client cannot. Writes go
|
|
7
|
+
* through the Host `configEditor` service, which validates, persists to the
|
|
8
|
+
* profile patch, and reconciles the Loader row.
|
|
9
|
+
*
|
|
10
|
+
* Secrets are write-only: the current inline token is reported as a boolean,
|
|
11
|
+
* never returned, and `signing.privateKeyPem` is not exposed at all.
|
|
12
|
+
*
|
|
13
|
+
* @module @ai_mind/dsh-a2a/panel
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import express from 'express'
|
|
17
|
+
import { normalizeConfig, resolveCredentials } from './config.js'
|
|
18
|
+
import { errorMessage, isLoopbackAddress, isPlainObject } from './util.js'
|
|
19
|
+
|
|
20
|
+
const PLUGIN_NAME = '@ai_mind/dsh-a2a'
|
|
21
|
+
const PLUGIN_ROW_NAME = '@ai_mind/dsh-a2a'
|
|
22
|
+
const PLUGIN_ROW_ID = 'a2a-server'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Editable fields, grouped for the settings panel. Labels are supplied by the
|
|
26
|
+
* client locale keyed on `path`.
|
|
27
|
+
*/
|
|
28
|
+
const FIELDS = [
|
|
29
|
+
{ path: 'card.name', type: 'string', group: 'card' },
|
|
30
|
+
{ path: 'card.description', type: 'textarea', group: 'card' },
|
|
31
|
+
{ path: 'card.version', type: 'string', group: 'card' },
|
|
32
|
+
{ path: 'server.publicUrl', type: 'string', group: 'server', nullable: true, placeholder: 'https://agent.example.com' },
|
|
33
|
+
{ path: 'server.host', type: 'string', group: 'server', placeholder: '127.0.0.1' },
|
|
34
|
+
{
|
|
35
|
+
path: 'server.port',
|
|
36
|
+
type: 'integer',
|
|
37
|
+
group: 'server',
|
|
38
|
+
nullable: true,
|
|
39
|
+
min: 1,
|
|
40
|
+
max: 65535,
|
|
41
|
+
placeholder: '留空 = 复用 DSH 端口',
|
|
42
|
+
},
|
|
43
|
+
{ path: 'server.mountPath', type: 'string', group: 'server' },
|
|
44
|
+
{ path: 'server.streaming', type: 'boolean', group: 'server' },
|
|
45
|
+
{ path: 'server.pushNotifications', type: 'boolean', group: 'server' },
|
|
46
|
+
{ path: 'server.extendedCard', type: 'boolean', group: 'server' },
|
|
47
|
+
{ path: 'auth.mode', type: 'enum', options: ['none', 'bearer', 'apiKey'], group: 'auth' },
|
|
48
|
+
{ path: 'auth.tokenEnv', type: 'string', group: 'auth', nullable: true, placeholder: 'A2A_AUTH_TOKEN' },
|
|
49
|
+
{ path: 'auth.allowRemoteUnauthenticated', type: 'boolean', group: 'auth' },
|
|
50
|
+
{ path: 'agent.cwd', type: 'string', group: 'agent', nullable: true, placeholder: '/srv/a2a-workspace' },
|
|
51
|
+
{ path: 'agent.preset', type: 'string', group: 'agent', nullable: true },
|
|
52
|
+
{ path: 'agent.provider', type: 'string', group: 'agent', nullable: true },
|
|
53
|
+
{ path: 'agent.model', type: 'string', group: 'agent', nullable: true },
|
|
54
|
+
{ path: 'agent.reasoningEffort', type: 'string', group: 'agent', nullable: true },
|
|
55
|
+
{
|
|
56
|
+
path: 'agent.sandboxMode',
|
|
57
|
+
type: 'enum',
|
|
58
|
+
options: ['inherit', 'read-only', 'workspace-write', 'danger-full-access'],
|
|
59
|
+
group: 'agent',
|
|
60
|
+
},
|
|
61
|
+
{ path: 'agent.approvalPolicy', type: 'enum', options: ['inherit', 'ask', 'never'], group: 'agent' },
|
|
62
|
+
{ path: 'agent.maxConcurrentTasks', type: 'integer', group: 'agent', min: 1, max: 64 },
|
|
63
|
+
{ path: 'agent.queueTimeoutMs', type: 'integer', group: 'agent', min: 0, max: 3600000 },
|
|
64
|
+
{ path: 'agent.turnTimeoutMs', type: 'integer', group: 'agent', min: 1000, max: 86400000 },
|
|
65
|
+
{ path: 'limits.maxRequestBytes', type: 'integer', group: 'limits', min: 1024, max: 268435456 },
|
|
66
|
+
{ path: 'limits.maxFileBytes', type: 'integer', group: 'limits', min: 0, max: 268435456 },
|
|
67
|
+
{ path: 'tasks.ttlMs', type: 'integer', group: 'limits', min: 0, max: 2592000000 },
|
|
68
|
+
{ path: 'tasks.maxTasks', type: 'integer', group: 'limits', min: 1, max: 100000 },
|
|
69
|
+
{ path: 'push.allowPrivateTargets', type: 'boolean', group: 'limits' },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
const FIELD_BY_PATH = new Map(FIELDS.map((field) => [field.path, field]))
|
|
73
|
+
|
|
74
|
+
function readAt(source, path) {
|
|
75
|
+
let cursor = source
|
|
76
|
+
for (const part of path.split('.')) {
|
|
77
|
+
if (!isPlainObject(cursor)) return undefined
|
|
78
|
+
cursor = cursor[part]
|
|
79
|
+
}
|
|
80
|
+
return cursor
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setAt(target, path, value) {
|
|
84
|
+
const parts = path.split('.')
|
|
85
|
+
let cursor = target
|
|
86
|
+
for (const part of parts.slice(0, -1)) {
|
|
87
|
+
if (!isPlainObject(cursor[part])) cursor[part] = {}
|
|
88
|
+
cursor = cursor[part]
|
|
89
|
+
}
|
|
90
|
+
cursor[parts[parts.length - 1]] = value
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deleteAt(target, path) {
|
|
94
|
+
const parts = path.split('.')
|
|
95
|
+
let cursor = target
|
|
96
|
+
for (const part of parts.slice(0, -1)) {
|
|
97
|
+
if (!isPlainObject(cursor) || !isPlainObject(cursor[part])) return
|
|
98
|
+
cursor = cursor[part]
|
|
99
|
+
}
|
|
100
|
+
delete cursor[parts[parts.length - 1]]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Merge values over `inherited` while preserving unrelated override keys. */
|
|
104
|
+
function mergeConfig(inherited, override) {
|
|
105
|
+
const base = structuredClone(inherited ?? {})
|
|
106
|
+
if (!isPlainObject(override)) return base
|
|
107
|
+
const merge = (target, patch) => {
|
|
108
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
109
|
+
if (isPlainObject(value) && isPlainObject(target[key])) merge(target[key], value)
|
|
110
|
+
else target[key] = structuredClone(value)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
merge(base, override)
|
|
114
|
+
return base
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Coerce one submitted value to the declared field type.
|
|
119
|
+
*
|
|
120
|
+
* @param {{ path: string, type: string, options?: string[], nullable?: boolean, min?: number, max?: number }} field
|
|
121
|
+
* @param {unknown} raw
|
|
122
|
+
* @returns {unknown} the coerced value, or `null` to unset the field
|
|
123
|
+
*/
|
|
124
|
+
function coerceField(field, raw) {
|
|
125
|
+
const label = field.path
|
|
126
|
+
if (raw === null || raw === undefined) {
|
|
127
|
+
if (field.nullable) return null
|
|
128
|
+
throw new Error(`${label} must not be empty`)
|
|
129
|
+
}
|
|
130
|
+
switch (field.type) {
|
|
131
|
+
case 'string':
|
|
132
|
+
case 'textarea': {
|
|
133
|
+
if (typeof raw !== 'string') throw new Error(`${label} must be a string`)
|
|
134
|
+
const value = raw.trim()
|
|
135
|
+
if (value === '') {
|
|
136
|
+
if (field.nullable) return null
|
|
137
|
+
throw new Error(`${label} must not be empty`)
|
|
138
|
+
}
|
|
139
|
+
return raw
|
|
140
|
+
}
|
|
141
|
+
case 'integer': {
|
|
142
|
+
const value = typeof raw === 'number' ? raw : Number(raw)
|
|
143
|
+
if (!Number.isInteger(value)) throw new Error(`${label} must be an integer`)
|
|
144
|
+
if (field.min !== undefined && value < field.min) throw new Error(`${label} must be >= ${field.min}`)
|
|
145
|
+
if (field.max !== undefined && value > field.max) throw new Error(`${label} must be <= ${field.max}`)
|
|
146
|
+
return value
|
|
147
|
+
}
|
|
148
|
+
case 'boolean': {
|
|
149
|
+
if (typeof raw !== 'boolean') throw new Error(`${label} must be a boolean`)
|
|
150
|
+
return raw
|
|
151
|
+
}
|
|
152
|
+
case 'enum': {
|
|
153
|
+
if (typeof raw !== 'string' || !field.options.includes(raw)) {
|
|
154
|
+
throw new Error(`${label} must be one of ${field.options.join(', ')}`)
|
|
155
|
+
}
|
|
156
|
+
return raw
|
|
157
|
+
}
|
|
158
|
+
default:
|
|
159
|
+
throw new Error(`${label} has an unsupported field type`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Build the GET payload: declared fields, values, secret presence. */
|
|
164
|
+
function describe(config, entry, warnings) {
|
|
165
|
+
if (!isPlainObject(config)) return { fields: [], secrets: { inline: false, tokenEnv: null }, warnings }
|
|
166
|
+
const fields = FIELDS.map((field) => {
|
|
167
|
+
const value = readAt(config, field.path)
|
|
168
|
+
return {
|
|
169
|
+
path: field.path,
|
|
170
|
+
type: field.type,
|
|
171
|
+
group: field.group,
|
|
172
|
+
...(field.options === undefined ? {} : { options: field.options }),
|
|
173
|
+
...(field.nullable === undefined ? {} : { nullable: field.nullable }),
|
|
174
|
+
...(field.min === undefined ? {} : { min: field.min }),
|
|
175
|
+
...(field.max === undefined ? {} : { max: field.max }),
|
|
176
|
+
...(field.placeholder === undefined ? {} : { placeholder: field.placeholder }),
|
|
177
|
+
value: value === undefined ? null : value,
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
return {
|
|
181
|
+
entryId: entry?.options?.id ?? PLUGIN_ROW_ID,
|
|
182
|
+
fields,
|
|
183
|
+
secrets: {
|
|
184
|
+
inline: typeof config.auth?.token === 'string' && config.auth.token !== '',
|
|
185
|
+
tokenEnv: typeof config.auth?.tokenEnv === 'string' ? config.auth.tokenEnv : null,
|
|
186
|
+
clientCount: Array.isArray(config.auth?.clients) ? config.auth.clients.length : 0,
|
|
187
|
+
},
|
|
188
|
+
warnings,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create the `/panel/config` router.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} options
|
|
196
|
+
* @param {object} options.ctx owning Cordis context
|
|
197
|
+
* @param {Readonly<object>} options.config the active normalized config
|
|
198
|
+
* @param {string} options.mountPath A2A mount path (for messages)
|
|
199
|
+
* @param {{ info: Function, warn: Function }} options.logger
|
|
200
|
+
* @param {() => object} [options.status] live listening-mode description
|
|
201
|
+
* @param {() => object} [options.health] live health snapshot
|
|
202
|
+
* @returns {import('express').Router}
|
|
203
|
+
*/
|
|
204
|
+
function safeHealth(health, logger) {
|
|
205
|
+
try {
|
|
206
|
+
return health()
|
|
207
|
+
} catch (error) {
|
|
208
|
+
logger.warn(`[a2a] panel health snapshot failed: ${errorMessage(error)}`)
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createPanelRouter({ ctx, config, mountPath, logger, status, health }) {
|
|
214
|
+
const router = express.Router()
|
|
215
|
+
const json = express.json({ limit: 256 * 1024, strict: false })
|
|
216
|
+
|
|
217
|
+
/** @returns {boolean} true when the request was rejected and already answered */
|
|
218
|
+
function authorize(req, res) {
|
|
219
|
+
const connection = ctx.get?.('connection')
|
|
220
|
+
if (connection !== undefined && typeof connection.requestRejection === 'function') {
|
|
221
|
+
try {
|
|
222
|
+
const rejection = connection.requestRejection({ headers: req.headers })
|
|
223
|
+
if (rejection !== undefined) {
|
|
224
|
+
res.status(rejection).json({
|
|
225
|
+
error: rejection === 401 ? 'UNAUTHENTICATED' : 'FORBIDDEN',
|
|
226
|
+
message: 'This endpoint requires the authenticated Harness Web session.',
|
|
227
|
+
})
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
return false
|
|
231
|
+
} catch (error) {
|
|
232
|
+
logger.warn(`[a2a] panel authorization failed: ${errorMessage(error)}`)
|
|
233
|
+
res.status(403).json({ error: 'FORBIDDEN', message: 'Cannot verify the Harness Web session.' })
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress ?? '')) {
|
|
238
|
+
res.status(403).json({ error: 'FORBIDDEN', message: 'Configuration editing is limited to the local Harness host.' })
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function editor() {
|
|
245
|
+
return ctx.get?.('configEditor')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function locateEntry(configEditor) {
|
|
249
|
+
const entries = configEditor.entries()
|
|
250
|
+
return (
|
|
251
|
+
entries.find((entry) => entry.options?.name === PLUGIN_ROW_NAME) ??
|
|
252
|
+
entries.find((entry) => entry.options?.name === PLUGIN_NAME) ??
|
|
253
|
+
entries.find((entry) => entry.options?.id === PLUGIN_ROW_ID)
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
router.get('/config', (req, res) => {
|
|
258
|
+
if (authorize(req, res)) return
|
|
259
|
+
const configEditor = editor()
|
|
260
|
+
if (configEditor === undefined) {
|
|
261
|
+
res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
let entry
|
|
265
|
+
try {
|
|
266
|
+
entry = locateEntry(configEditor)
|
|
267
|
+
} catch (error) {
|
|
268
|
+
res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (entry === undefined) {
|
|
272
|
+
res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
let live = config
|
|
276
|
+
try {
|
|
277
|
+
live = normalizeConfig(entry.options?.config ?? {})
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logger.warn(`[a2a] live config is invalid, showing the active snapshot: ${errorMessage(error)}`)
|
|
280
|
+
}
|
|
281
|
+
const warnings = []
|
|
282
|
+
if (live.server.extendedCard && !resolveCredentials(live).length) {
|
|
283
|
+
warnings.push('extendedCard is disabled at mount unless authentication is configured.')
|
|
284
|
+
}
|
|
285
|
+
const payload = describe(live, entry, warnings)
|
|
286
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
287
|
+
res.json({
|
|
288
|
+
ok: true,
|
|
289
|
+
plugin: { name: PLUGIN_NAME, version: '1.1.0' },
|
|
290
|
+
mountPath,
|
|
291
|
+
...payload,
|
|
292
|
+
...(typeof status === 'function' ? { server: status() } : {}),
|
|
293
|
+
...(typeof health === 'function' ? { health: safeHealth(health, logger) } : {}),
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
router.put('/config', json, async (req, res) => {
|
|
298
|
+
if (authorize(req, res)) return
|
|
299
|
+
const configEditor = editor()
|
|
300
|
+
if (configEditor === undefined) {
|
|
301
|
+
res.status(501).json({ error: 'UNAVAILABLE', message: 'Configuration editing requires the Harness config editor.' })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
let entry
|
|
305
|
+
let layers
|
|
306
|
+
try {
|
|
307
|
+
entry = locateEntry(configEditor)
|
|
308
|
+
layers = configEditor.configuration().find((item) => item.entry === entry)
|
|
309
|
+
} catch (error) {
|
|
310
|
+
res.status(500).json({ error: 'INTERNAL', message: errorMessage(error) })
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
if (entry === undefined || layers === undefined) {
|
|
314
|
+
res.status(404).json({ error: 'NOT_FOUND', message: `No active ${PLUGIN_NAME} row is managed by this profile.` })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const body = req.body
|
|
319
|
+
if (!isPlainObject(body) || (!isPlainObject(body.values) && body.token === undefined && body.clearToken !== true)) {
|
|
320
|
+
res.status(400).json({ error: 'INVALID_ARGUMENT', message: 'Expected { values, token?, clearToken? }.' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const next = mergeConfig(layers.inherited, layers.override)
|
|
325
|
+
try {
|
|
326
|
+
for (const [path, raw] of Object.entries(body.values ?? {})) {
|
|
327
|
+
const field = FIELD_BY_PATH.get(path)
|
|
328
|
+
if (field === undefined) throw new Error(`unknown configuration field "${path}"`)
|
|
329
|
+
const value = coerceField(field, raw)
|
|
330
|
+
if (value === null) {
|
|
331
|
+
deleteAt(next, path)
|
|
332
|
+
const inheritedValue = readAt(layers.inherited, path)
|
|
333
|
+
if (inheritedValue !== undefined) setAt(next, path, structuredClone(inheritedValue))
|
|
334
|
+
} else {
|
|
335
|
+
setAt(next, path, value)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (body.clearToken === true) {
|
|
340
|
+
deleteAt(next, 'auth.token')
|
|
341
|
+
} else if (body.token !== undefined) {
|
|
342
|
+
if (typeof body.token !== 'string') throw new Error('token must be a string')
|
|
343
|
+
if (body.token.trim() === '') throw new Error('token must not be empty')
|
|
344
|
+
setAt(next, 'auth.token', body.token)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const normalized = normalizeConfig(next)
|
|
348
|
+
const credentials = resolveCredentials(normalized)
|
|
349
|
+
if (normalized.auth.mode !== 'none' && credentials.length === 0) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
normalized.auth.tokenEnv
|
|
352
|
+
? `auth.tokenEnv "${normalized.auth.tokenEnv}" is empty; set the environment variable or provide a token`
|
|
353
|
+
: 'auth.mode requires auth.token, auth.tokens, auth.clients or auth.tokenEnv',
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
res.status(400).json({ error: 'INVALID_ARGUMENT', message: errorMessage(error) })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await configEditor.edit(entry, () => next)
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logger.warn(`[a2a] config edit failed: ${errorMessage(error)}`)
|
|
365
|
+
res.status(409).json({ error: 'CONFLICT', message: errorMessage(error) })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The row may have been replaced while reconciling; read the live config
|
|
370
|
+
// back rather than echoing our submitted draft.
|
|
371
|
+
let live = next
|
|
372
|
+
let liveEntry = entry
|
|
373
|
+
try {
|
|
374
|
+
const fresh = locateEntry(configEditor)
|
|
375
|
+
if (fresh !== undefined) liveEntry = fresh
|
|
376
|
+
live = normalizeConfig(fresh?.options?.config ?? next)
|
|
377
|
+
} catch {
|
|
378
|
+
/* keep the submitted view when the row is mid-reload */
|
|
379
|
+
}
|
|
380
|
+
logger.info('[a2a] configuration updated from the settings panel')
|
|
381
|
+
res.json({
|
|
382
|
+
ok: true,
|
|
383
|
+
plugin: { name: PLUGIN_NAME, version: '1.1.0' },
|
|
384
|
+
mountPath,
|
|
385
|
+
...describe(live, liveEntry, []),
|
|
386
|
+
...(typeof status === 'function' ? { server: status() } : {}),
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
return router
|
|
391
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an inbound A2A message into one DeepSeek Harness prompt.
|
|
3
|
+
*
|
|
4
|
+
* Text parts become prompt text, structured `data` parts become fenced JSON,
|
|
5
|
+
* URL parts are referenced verbatim, and `raw` file parts are written into the
|
|
6
|
+
* session workspace so the agent's sandboxed tools can read them.
|
|
7
|
+
*
|
|
8
|
+
* @module @ai_mind/dsh-a2a/prompt
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { join, resolve, sep } from 'node:path'
|
|
13
|
+
import { newId, safeFileName, truncate } from './util.js'
|
|
14
|
+
|
|
15
|
+
/** Common media types mapped to a file extension for materialized files. */
|
|
16
|
+
const MEDIA_EXTENSIONS = {
|
|
17
|
+
'text/plain': '.txt',
|
|
18
|
+
'text/markdown': '.md',
|
|
19
|
+
'text/csv': '.csv',
|
|
20
|
+
'application/json': '.json',
|
|
21
|
+
'application/pdf': '.pdf',
|
|
22
|
+
'application/zip': '.zip',
|
|
23
|
+
'image/png': '.png',
|
|
24
|
+
'image/jpeg': '.jpg',
|
|
25
|
+
'image/webp': '.webp',
|
|
26
|
+
'image/gif': '.gif',
|
|
27
|
+
'audio/mpeg': '.mp3',
|
|
28
|
+
'audio/wav': '.wav',
|
|
29
|
+
'video/mp4': '.mp4',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extensionFor(mediaType) {
|
|
33
|
+
if (typeof mediaType !== 'string') return ''
|
|
34
|
+
const base = mediaType.split(';', 1)[0].trim().toLowerCase()
|
|
35
|
+
return MEDIA_EXTENSIONS[base] ?? ''
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeRawValue(value) {
|
|
39
|
+
if (typeof value === 'string') return Buffer.from(value, 'base64')
|
|
40
|
+
if (Buffer.isBuffer(value)) return value
|
|
41
|
+
if (value instanceof Uint8Array) return Buffer.from(value)
|
|
42
|
+
throw new Error('file part has an unsupported raw value')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ensure `child` resolves inside `parent`.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} parent absolute directory
|
|
49
|
+
* @param {string} child candidate path
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function assertInside(parent, child) {
|
|
53
|
+
const root = resolve(parent)
|
|
54
|
+
const target = resolve(child)
|
|
55
|
+
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
56
|
+
throw new Error('resolved attachment path escapes the workspace directory')
|
|
57
|
+
}
|
|
58
|
+
return target
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the prompt for one A2A message.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} options
|
|
65
|
+
* @param {object} options.message A2A `Message` in SDK object form
|
|
66
|
+
* @param {Readonly<object>} options.config normalized plugin configuration
|
|
67
|
+
* @param {string} options.cwd absolute workspace directory for this session
|
|
68
|
+
* @param {{ warn: (...args: unknown[]) => void }} options.logger
|
|
69
|
+
* @returns {Promise<{ text: string, files: { name: string, path: string, mediaType: string, bytes: number }[] }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function buildAgentPrompt({ message, config, cwd, logger }) {
|
|
72
|
+
const blocks = []
|
|
73
|
+
const files = []
|
|
74
|
+
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
75
|
+
let rawIndex = 0
|
|
76
|
+
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
const content = part?.content
|
|
79
|
+
if (content === undefined || content === null) continue
|
|
80
|
+
switch (content.$case) {
|
|
81
|
+
case 'text': {
|
|
82
|
+
const value = typeof content.value === 'string' ? content.value : ''
|
|
83
|
+
if (value.trim() !== '') blocks.push(value)
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
case 'data': {
|
|
87
|
+
let rendered
|
|
88
|
+
try {
|
|
89
|
+
rendered = JSON.stringify(content.value, null, 2)
|
|
90
|
+
} catch {
|
|
91
|
+
rendered = String(content.value)
|
|
92
|
+
}
|
|
93
|
+
blocks.push(['```json', rendered, '```'].join('\n'))
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
case 'url': {
|
|
97
|
+
const label = part.filename ? ` "${part.filename}"` : ''
|
|
98
|
+
const type = part.mediaType ? ` (${part.mediaType})` : ''
|
|
99
|
+
blocks.push(`[Attached file${label}${type}]: ${content.value}`)
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
case 'raw': {
|
|
103
|
+
rawIndex += 1
|
|
104
|
+
const bytes = decodeRawValue(content.value)
|
|
105
|
+
if (bytes.byteLength > config.limits.maxFileBytes) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`file attachment is ${bytes.byteLength} bytes, exceeding limits.maxFileBytes (${config.limits.maxFileBytes})`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (!config.files.enabled) {
|
|
111
|
+
blocks.push(
|
|
112
|
+
`[Attached file${part.filename ? ` "${part.filename}"` : ''} was received but files.enabled is false; contents were not written to disk]`,
|
|
113
|
+
)
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
const extension = extensionFor(part.mediaType)
|
|
117
|
+
const baseName = part.filename ? safeFileName(part.filename) : `attachment-${rawIndex}${extension}`
|
|
118
|
+
const directoryName = safeFileName(message?.taskId || newId(), 'task')
|
|
119
|
+
const directory = assertInside(cwd, join(cwd, config.files.directory, directoryName))
|
|
120
|
+
const uniquePrefix = `${String(rawIndex).padStart(2, '0')}-`
|
|
121
|
+
const target = assertInside(cwd, join(directory, `${uniquePrefix}${baseName}`))
|
|
122
|
+
await mkdir(directory, { recursive: true })
|
|
123
|
+
await writeFile(target, bytes, { mode: 0o600 })
|
|
124
|
+
files.push({
|
|
125
|
+
name: baseName,
|
|
126
|
+
path: target,
|
|
127
|
+
mediaType: part.mediaType ?? 'application/octet-stream',
|
|
128
|
+
bytes: bytes.byteLength,
|
|
129
|
+
})
|
|
130
|
+
logger.info(`[a2a] materialized attachment ${target} (${bytes.byteLength} bytes)`)
|
|
131
|
+
blocks.push(
|
|
132
|
+
`[Attached file "${baseName}" (${part.mediaType ?? 'application/octet-stream'}) is available at: ${target}]`,
|
|
133
|
+
)
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
default:
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (blocks.length === 0) {
|
|
142
|
+
throw new Error('message contains no usable text, data, URL or raw file parts')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const text = truncate(blocks.join('\n\n'), config.limits.maxPromptChars)
|
|
146
|
+
return { text, files }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {object} message A2A `Message` in SDK object form
|
|
151
|
+
* @returns {string} concatenated text parts only, for status messages/logging
|
|
152
|
+
*/
|
|
153
|
+
export function messageText(message) {
|
|
154
|
+
if (!Array.isArray(message?.parts)) return ''
|
|
155
|
+
return message.parts
|
|
156
|
+
.map((part) => (part?.content?.$case === 'text' ? part.content.value : ''))
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(' ')
|
|
159
|
+
.trim()
|
|
160
|
+
}
|
|
161
|
+
|