@ryubyte/dsh-a2a 0.1.3 → 0.1.4
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.en.md +54 -11
- package/README.md +23 -6
- package/lib/client.js +110 -12
- package/lib/index.js +454 -26
- package/lib/types/a2a-config.d.ts +2 -0
- package/lib/types/a2a-config.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/dashboard.d.ts +8 -2
- package/lib/types/dashboard.d.ts.map +1 -1
- package/lib/types/index.d.ts +41 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/outbound.d.ts.map +1 -1
- package/lib/types/server.d.ts +225 -1
- package/lib/types/server.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/a2a-config.ts +3 -0
- package/src/client/index.ts +111 -12
- package/src/dashboard.ts +16 -4
- package/src/index.ts +221 -16
- package/src/outbound.ts +31 -4
- package/src/server.ts +474 -9
package/src/client/index.ts
CHANGED
|
@@ -115,6 +115,10 @@ export function DashboardSection(_props: SectionProps): ReactElement {
|
|
|
115
115
|
const [tab, setTab] = useState<'out' | 'in' | 'serve'>('out');
|
|
116
116
|
/** connection ids whose skill list is expanded in the outbound tab. */
|
|
117
117
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
118
|
+
/** Optional bearer token for the add-agent form (outbound). */
|
|
119
|
+
const [addToken, setAddToken] = useState('');
|
|
120
|
+
/** Inbound authToken input (serve tab); empty string means "clear". */
|
|
121
|
+
const [authInput, setAuthInput] = useState('');
|
|
118
122
|
|
|
119
123
|
interface ServeStatus {
|
|
120
124
|
enabled: boolean;
|
|
@@ -125,7 +129,12 @@ export function DashboardSection(_props: SectionProps): ReactElement {
|
|
|
125
129
|
endpoint?: string;
|
|
126
130
|
agentCardUrl?: string;
|
|
127
131
|
skills?: Array<{ id: string; name: string; description?: string }>;
|
|
132
|
+
/** Legacy: true when a custom executor was injected (kept for old snapshots). */
|
|
128
133
|
customExecutor?: boolean;
|
|
134
|
+
/** How inbound tasks are executed. */
|
|
135
|
+
executor?: 'custom' | 'dsh-agent' | 'none';
|
|
136
|
+
/** Whether the inbound endpoint is token-gated (never the token value). */
|
|
137
|
+
authConfigured?: boolean;
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
const refresh = async (): Promise<void> => {
|
|
@@ -191,7 +200,7 @@ const doDiscover = async (): Promise<void> => {
|
|
|
191
200
|
const res = await fetch('/a2a/api', {
|
|
192
201
|
method: 'POST',
|
|
193
202
|
headers: { 'content-type': 'application/json' },
|
|
194
|
-
body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u }),
|
|
203
|
+
body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u, bearerToken: addToken.trim() || undefined }),
|
|
195
204
|
});
|
|
196
205
|
const body = (await res.json()) as ControlResultApi;
|
|
197
206
|
if (!body.ok || !body.message) {
|
|
@@ -209,9 +218,10 @@ const doDiscover = async (): Promise<void> => {
|
|
|
209
218
|
const doConnect = async (): Promise<void> => {
|
|
210
219
|
if (!discovered) return;
|
|
211
220
|
const n = discovered.name.trim() || 'remote';
|
|
212
|
-
await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl });
|
|
221
|
+
await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl, bearerToken: addToken.trim() || undefined });
|
|
213
222
|
if (!busy) {
|
|
214
223
|
setAddUrl('');
|
|
224
|
+
setAddToken('');
|
|
215
225
|
setDiscovered(null);
|
|
216
226
|
}
|
|
217
227
|
};
|
|
@@ -284,6 +294,22 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
|
|
|
284
294
|
}),
|
|
285
295
|
actionBtn('导入', false, () => void doDiscover(), discovering || !addUrl.trim()),
|
|
286
296
|
),
|
|
297
|
+
createElement(
|
|
298
|
+
'div',
|
|
299
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
300
|
+
createElement('input', {
|
|
301
|
+
type: 'password',
|
|
302
|
+
placeholder: 'Bearer Token(可选,发送为 Authorization: Bearer …)',
|
|
303
|
+
value: addToken,
|
|
304
|
+
onChange: (e: { target: { value: string } }) => setAddToken(e.target.value),
|
|
305
|
+
style: { ...inputStyle, flex: 1, minWidth: 260 },
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
308
|
+
createElement(
|
|
309
|
+
'div',
|
|
310
|
+
{ style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
|
|
311
|
+
'若远程 Agent 需要鉴权,填入 token;导入与后续调用都会带上。token 会明文存入 a2a.json。',
|
|
312
|
+
),
|
|
287
313
|
discovering
|
|
288
314
|
? createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '正在读取 Agent Card…')
|
|
289
315
|
: discovered
|
|
@@ -318,7 +344,7 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
|
|
|
318
344
|
createElement(
|
|
319
345
|
'div',
|
|
320
346
|
{ style: { display: 'flex', gap: 8, marginTop: 2 } },
|
|
321
|
-
actionBtn('连接', true, () => void doConnect(), busy
|
|
347
|
+
actionBtn('连接', true, () => void doConnect(), busy?.startsWith('add-agent:') ?? false),
|
|
322
348
|
actionBtn('取消', false, () => { setDiscovered(null); setAddUrl(''); }, false),
|
|
323
349
|
),
|
|
324
350
|
)
|
|
@@ -349,8 +375,8 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
|
|
|
349
375
|
'span',
|
|
350
376
|
{ style: { marginLeft: 'auto' } },
|
|
351
377
|
serve.enabled
|
|
352
|
-
? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable')
|
|
353
|
-
: actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable'),
|
|
378
|
+
? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable:')
|
|
379
|
+
: actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable:'),
|
|
354
380
|
),
|
|
355
381
|
),
|
|
356
382
|
createElement(
|
|
@@ -384,13 +410,8 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
|
|
|
384
410
|
)
|
|
385
411
|
: null,
|
|
386
412
|
),
|
|
387
|
-
serve.enabled
|
|
388
|
-
|
|
389
|
-
'div',
|
|
390
|
-
{ style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
|
|
391
|
-
'⚠ 默认 executor 会执行 shell 命令。生产环境请通过配置指定受限的 execute 实现。',
|
|
392
|
-
)
|
|
393
|
-
: null,
|
|
413
|
+
serve.enabled ? executorNote(serve) : null,
|
|
414
|
+
serve.enabled ? authControl(serve, busy, act, authInput, setAuthInput) : null,
|
|
394
415
|
)
|
|
395
416
|
: null,
|
|
396
417
|
// ── inbound tab ────────────────────────────────────────
|
|
@@ -594,6 +615,84 @@ function actionBtn(label: string, primary: boolean, onClick: () => void, disable
|
|
|
594
615
|
);
|
|
595
616
|
}
|
|
596
617
|
|
|
618
|
+
/**
|
|
619
|
+
* A semantically-correct note about how inbound tasks are executed. Dispatches
|
|
620
|
+
* on the `executor` field; falls back to the legacy `customExecutor` boolean
|
|
621
|
+
* for snapshots from an older host that didn't send `executor`.
|
|
622
|
+
*/
|
|
623
|
+
function executorNote(serve: {
|
|
624
|
+
executor?: 'custom' | 'dsh-agent' | 'none';
|
|
625
|
+
customExecutor?: boolean;
|
|
626
|
+
}): ReactElement | null {
|
|
627
|
+
const kind = serve.executor ?? (serve.customExecutor ? 'custom' : undefined);
|
|
628
|
+
if (kind === 'custom') return null; // operator wired their own executor
|
|
629
|
+
if (kind === 'dsh-agent') {
|
|
630
|
+
return createElement(
|
|
631
|
+
'div',
|
|
632
|
+
{ style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', background: 'rgba(74,125,255,.08)', borderRadius: 6, padding: '6px 10px' } },
|
|
633
|
+
'ℹ 入站任务由本 DSH 的 agent 会话执行,按 A2A contextId 一个对话一个会话。',
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (kind === 'none') {
|
|
637
|
+
return createElement(
|
|
638
|
+
'div',
|
|
639
|
+
{ style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
|
|
640
|
+
'⚠ 未配置 executor,且未检测到可用的 agent 循环:入站任务会被拒绝。通过配置注入 execute,或加载 agent-loop 插件。',
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Inbound bearer-token control for the serve tab: shows whether the endpoint is
|
|
648
|
+
* token-gated, and lets the operator set or clear the shared token. The token
|
|
649
|
+
* value is never read back from the host — only whether one is configured.
|
|
650
|
+
*/
|
|
651
|
+
function authControl(
|
|
652
|
+
serve: { authConfigured?: boolean },
|
|
653
|
+
busy: string | null,
|
|
654
|
+
act: (a: string, t: string, p?: Record<string, unknown>) => Promise<void>,
|
|
655
|
+
authInput: string,
|
|
656
|
+
setAuthInput: (v: string) => void,
|
|
657
|
+
): ReactElement {
|
|
658
|
+
const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3))', background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.06))', color: 'var(--dsw-alias-label-primary, #222)' };
|
|
659
|
+
return createElement(
|
|
660
|
+
'div',
|
|
661
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
|
|
662
|
+
createElement(
|
|
663
|
+
'div',
|
|
664
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
665
|
+
createElement('span', { style: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, '入站鉴权'),
|
|
666
|
+
stateDot(serve.authConfigured ? 'connected' : 'disabled'),
|
|
667
|
+
createElement(
|
|
668
|
+
'span',
|
|
669
|
+
{ style: { fontSize: 12, color: serve.authConfigured ? '#2e7d32' : '#9e9e9e' } },
|
|
670
|
+
serve.authConfigured ? '已启用 Bearer 鉴权' : '未鉴权(任何客户端可调用)',
|
|
671
|
+
),
|
|
672
|
+
),
|
|
673
|
+
createElement(
|
|
674
|
+
'div',
|
|
675
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
|
|
676
|
+
createElement('input', {
|
|
677
|
+
type: 'password',
|
|
678
|
+
placeholder: serve.authConfigured ? '输入新 token 以替换' : '设置 Bearer Token 以开启鉴权',
|
|
679
|
+
value: authInput,
|
|
680
|
+
onChange: (e: { target: { value: string } }) => setAuthInput(e.target.value),
|
|
681
|
+
style: { ...inputStyle, flex: 1, minWidth: 220 },
|
|
682
|
+
}),
|
|
683
|
+
actionBtn('保存', true, () => { void act('set-server-auth', '', { authToken: authInput.trim() }); setAuthInput(''); }, busy === 'set-server-auth:' || !authInput.trim()),
|
|
684
|
+
serve.authConfigured
|
|
685
|
+
? actionBtn('清除', false, () => { void act('set-server-auth', '', { authToken: '' }); setAuthInput(''); }, busy === 'set-server-auth:')
|
|
686
|
+
: null,
|
|
687
|
+
),
|
|
688
|
+
createElement(
|
|
689
|
+
'div',
|
|
690
|
+
{ style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
|
|
691
|
+
'token 会明文存入 a2a.json,重启后仍生效。设置后,入站 JSON-RPC 请求需带 Authorization: Bearer <token>。',
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
597
696
|
function inboundRow(p: InboundPeerApi, busy: string | null, act: (a: string, t: string) => void): ReactElement {
|
|
598
697
|
return createElement(
|
|
599
698
|
'tr',
|
package/src/dashboard.ts
CHANGED
|
@@ -91,11 +91,13 @@ export interface DashboardControlHooks {
|
|
|
91
91
|
/** Reconnect is not meaningful for an inbound peer — rejects unless overridden. */
|
|
92
92
|
reconnectPeer?: (peerId: string) => Promise<ControlResult>;
|
|
93
93
|
/** Runtime-add an outbound agent (visual config). Returns the new connection id. */
|
|
94
|
-
addAgent?: (name: string, agentCardUrl: string) => Promise<ControlResult>;
|
|
94
|
+
addAgent?: (name: string, agentCardUrl: string, opts?: { bearerToken?: string }) => Promise<ControlResult>;
|
|
95
95
|
/** Discover an Agent Card without connecting (for the import preview). */
|
|
96
|
-
discoverAgent?: (agentCardUrl: string) => Promise<ControlResult>;
|
|
96
|
+
discoverAgent?: (agentCardUrl: string, opts?: { bearerToken?: string }) => Promise<ControlResult>;
|
|
97
97
|
/** Enable/disable the inbound A2A server (serve routes) at runtime. */
|
|
98
98
|
setServerEnabled?: (enabled: boolean) => Promise<ControlResult>;
|
|
99
|
+
/** Set or clear the inbound server's shared bearer token at runtime (persisted). */
|
|
100
|
+
setServerAuthToken?: (token?: string) => Promise<ControlResult>;
|
|
99
101
|
/** Read current inbound server status (for the serve panel). */
|
|
100
102
|
serverStatus?: () => ControlResult;
|
|
101
103
|
/** Runtime-remove an outbound agent by its connection id. */
|
|
@@ -271,16 +273,18 @@ export class DashboardRegistry {
|
|
|
271
273
|
case 'discover-agent': {
|
|
272
274
|
const url = (payload?.agentCardUrl as string) ?? target;
|
|
273
275
|
if (!url) return { ok: false, message: 'discover-agent requires agentCardUrl' };
|
|
276
|
+
const bearerToken = (payload?.bearerToken as string) || undefined;
|
|
274
277
|
return this.hooks.discoverAgent
|
|
275
|
-
? this.hooks.discoverAgent(url)
|
|
278
|
+
? this.hooks.discoverAgent(url, { bearerToken })
|
|
276
279
|
: { ok: false, message: 'discover-agent is not wired' };
|
|
277
280
|
}
|
|
278
281
|
case 'add-agent': {
|
|
279
282
|
const name = (payload?.name as string) ?? target;
|
|
280
283
|
const url = payload?.agentCardUrl as string | undefined;
|
|
281
284
|
if (!url) return { ok: false, message: 'add-agent requires agentCardUrl' };
|
|
285
|
+
const bearerToken = (payload?.bearerToken as string) || undefined;
|
|
282
286
|
return this.hooks.addAgent
|
|
283
|
-
? this.hooks.addAgent(name, url)
|
|
287
|
+
? this.hooks.addAgent(name, url, { bearerToken })
|
|
284
288
|
: { ok: false, message: 'add-agent is not wired (client mode not mounted)' };
|
|
285
289
|
}
|
|
286
290
|
case 'remove-agent':
|
|
@@ -295,6 +299,14 @@ export class DashboardRegistry {
|
|
|
295
299
|
return this.hooks.setServerEnabled
|
|
296
300
|
? this.hooks.setServerEnabled(false)
|
|
297
301
|
: { ok: false, message: 'server control is not wired (server mode not mounted)' };
|
|
302
|
+
case 'set-server-auth': {
|
|
303
|
+
// Empty/missing token clears the gate. The token value is never echoed
|
|
304
|
+
// back in the result message.
|
|
305
|
+
const token = (payload?.authToken as string) || undefined;
|
|
306
|
+
return this.hooks.setServerAuthToken
|
|
307
|
+
? this.hooks.setServerAuthToken(token)
|
|
308
|
+
: { ok: false, message: 'set-server-auth is not wired (server mode not mounted)' };
|
|
309
|
+
}
|
|
298
310
|
case 'server-status':
|
|
299
311
|
return this.hooks.serverStatus
|
|
300
312
|
? Promise.resolve(this.hooks.serverStatus())
|
package/src/index.ts
CHANGED
|
@@ -23,10 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis';
|
|
25
25
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
26
|
+
import { mkdirSync } from 'node:fs';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
26
28
|
import { registerAgentTools } from './outbound.js';
|
|
27
29
|
import { fetchAgentCard, pickInterface } from './card.js';
|
|
28
|
-
import { A2AServer, type ServerOptions } from './server.js';
|
|
30
|
+
import { A2AServer, type ServerOptions, createDshAgentExecutor, type AgentRegistryLike } from './server.js';
|
|
29
31
|
import { TaskStore } from './server.js';
|
|
32
|
+
import type { AgentPresetsLike } from './server.js';
|
|
30
33
|
import { getSharedRegistry } from './dashboard.js';
|
|
31
34
|
import {
|
|
32
35
|
loadConfig as loadPersistedA2A,
|
|
@@ -54,6 +57,8 @@ export interface A2APluginConfig {
|
|
|
54
57
|
skills?: ServerOptions['skills'];
|
|
55
58
|
endpointPath?: string;
|
|
56
59
|
execute?: ServerOptions['execute'];
|
|
60
|
+
/** Optional shared bearer token protecting the inbound /a2a endpoint. */
|
|
61
|
+
authToken?: string;
|
|
57
62
|
// dashboard
|
|
58
63
|
/** Serve the dashboard API on the webserver (default true). */
|
|
59
64
|
dashboard?: boolean;
|
|
@@ -63,7 +68,8 @@ export { A2AClient } from './client.js';
|
|
|
63
68
|
export * from './protocol.js';
|
|
64
69
|
export * from './errors.js';
|
|
65
70
|
export { fetchAgentCard } from './card.js';
|
|
66
|
-
export { A2AServer, TaskStore, defaultExecutor, defaultSkills } from './server.js';
|
|
71
|
+
export { A2AServer, TaskStore, defaultExecutor, notConfiguredExecutor, shellExecutor, defaultSkills, createDshAgentExecutor } from './server.js';
|
|
72
|
+
export type { AgentRegistryLike, AgentPresetsLike, DshAgentExecutorOptions } from './server.js';
|
|
67
73
|
export { registerAgentTools } from './outbound.js';
|
|
68
74
|
export { DashboardRegistry, getSharedRegistry } from './dashboard.js';
|
|
69
75
|
export type {
|
|
@@ -91,12 +97,46 @@ export interface WebServerService {
|
|
|
91
97
|
}): () => void;
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Structural slice of `ctx.agentDefaultModel` (owned by
|
|
102
|
+
* `@deepseek-ai/dsh-agent-default-model`). It holds the deployment's default
|
|
103
|
+
* `{ provider, model }` (layered with the user's Settings choice). Reading it
|
|
104
|
+
* to seed a newly-created agent's model is the creating entry point's job — a
|
|
105
|
+
* fresh session has no model selection, and DSH's persona system prompt fails
|
|
106
|
+
* assembly on an empty `{{model}}` variable otherwise.
|
|
107
|
+
*/
|
|
108
|
+
export interface AgentDefaultModelService {
|
|
109
|
+
currentSelection(): { provider?: string; model?: string; reasoningEffort?: string };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Structural slice of `ctx.sessionTitle` (owned by `@deepseek-ai/dsh-session-title`).
|
|
114
|
+
* `rename` accepts an explicit user title and pins it. We name inbound sessions
|
|
115
|
+
* ourselves because their prompts carry a `plugin` source, which the title
|
|
116
|
+
* service's automatic first-prompt naming deliberately ignores (human messages
|
|
117
|
+
* only) — so without this they fall back to the cwd basename.
|
|
118
|
+
*/
|
|
119
|
+
export interface SessionTitleService {
|
|
120
|
+
rename(session: unknown, title: string): unknown;
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
declare module '@deepseek-ai/cordis' {
|
|
95
124
|
interface Context {
|
|
96
125
|
/** Provided by @deepseek-ai/dsh-host-webserver. */
|
|
97
126
|
webServer: WebServerService;
|
|
127
|
+
/** Provided by @deepseek-ai/dsh-agent-default-model (optional). */
|
|
128
|
+
agentDefaultModel?: AgentDefaultModelService;
|
|
129
|
+
/** Provided by @deepseek-ai/dsh-session-title (optional). */
|
|
130
|
+
sessionTitle?: SessionTitleService;
|
|
98
131
|
/** Provided by @deepseek-ai/dsh-tools. */
|
|
99
132
|
tools: { register(def: unknown): () => void };
|
|
133
|
+
/**
|
|
134
|
+
* Provided by @deepseek-ai/dsh-agent (the agent registry). Present whenever
|
|
135
|
+
* that layer is composed, but `create()` only succeeds once an agent-loop
|
|
136
|
+
* plugin has registered its factory — so server mode probes readiness
|
|
137
|
+
* before enabling {@link createDshAgentExecutor}, and never hard-injects it.
|
|
138
|
+
*/
|
|
139
|
+
agents?: AgentRegistryLike;
|
|
100
140
|
}
|
|
101
141
|
}
|
|
102
142
|
|
|
@@ -365,19 +405,20 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
365
405
|
dashboard.removeAgent(connectionId);
|
|
366
406
|
return { ok: true, message: `connection ${connectionId} reconnected as ${newId}` };
|
|
367
407
|
},
|
|
368
|
-
addAgent: async (name, agentCardUrl) => {
|
|
369
|
-
const
|
|
408
|
+
addAgent: async (name, agentCardUrl, opts) => {
|
|
409
|
+
const bearerToken = opts?.bearerToken;
|
|
410
|
+
const cid = await connect(name, agentCardUrl, true, { bearerToken });
|
|
370
411
|
if (!cid) return { ok: false, message: `failed to connect to ${agentCardUrl}` };
|
|
371
|
-
// Persist: add to a2a.json so it survives restart.
|
|
372
|
-
initialAgents.push({ name, agentCardUrl, configured: true, enabled: true });
|
|
412
|
+
// Persist: add to a2a.json so it survives restart (token included).
|
|
413
|
+
initialAgents.push({ name, agentCardUrl, configured: true, enabled: true, bearerToken });
|
|
373
414
|
persistAgents();
|
|
374
415
|
return { ok: true, message: `agent "${name}" connected as ${cid} (saved to ${configPath})` };
|
|
375
416
|
},
|
|
376
|
-
discoverAgent: async (agentCardUrl) => {
|
|
417
|
+
discoverAgent: async (agentCardUrl, opts) => {
|
|
377
418
|
try {
|
|
378
419
|
const card = await fetchAgentCard(agentCardUrl, {
|
|
379
420
|
timeoutMs: mergedConfig.timeoutMs ?? 8000,
|
|
380
|
-
bearerToken: mergedConfig.bearerToken,
|
|
421
|
+
bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken,
|
|
381
422
|
});
|
|
382
423
|
const iface = pickInterface(card);
|
|
383
424
|
return {
|
|
@@ -473,6 +514,59 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
473
514
|
`http://${webServer.host === '0.0.0.0' ? '127.0.0.1' : webServer.host}:${webServer.port}`;
|
|
474
515
|
const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
|
|
475
516
|
|
|
517
|
+
// ── executor precedence ───────────────────────────────────────────────
|
|
518
|
+
// 1. explicit `execute` (config injection) → use it
|
|
519
|
+
// 2. no explicit executor & the agent loop is ready → dshAgentExecutor:
|
|
520
|
+
// inbound message → a fresh, workspace-bound DSH agent session →
|
|
521
|
+
// reply. "Ready" means the agent-loop factory is registered, not
|
|
522
|
+
// merely that `ctx.agents` exists — a bare dsh-base profile has the
|
|
523
|
+
// registry but `create()` would reject. We probe via the reflection
|
|
524
|
+
// layer (no inject requirement) so a composition without the agent
|
|
525
|
+
// layer simply falls through.
|
|
526
|
+
// 3. otherwise → A2AServer's
|
|
527
|
+
// default notConfiguredExecutor (refuses + points at `execute`).
|
|
528
|
+
let execute = mergedConfig.execute;
|
|
529
|
+
let executorKind: 'custom' | 'dsh-agent' | 'none' = execute ? 'custom' : 'none';
|
|
530
|
+
let disposeExecutor: (() => Promise<void>) | undefined;
|
|
531
|
+
if (!execute) {
|
|
532
|
+
const agents = probeAgentRegistry(ctx);
|
|
533
|
+
if (agents) {
|
|
534
|
+
// Dedicated cwd for inbound sessions: a subdir of the profile dir,
|
|
535
|
+
// NOT the profile root — so per-context sessions don't pollute the
|
|
536
|
+
// profile-root session group with a burst of `.a2a`-named rows. DSH
|
|
537
|
+
// requires an absolute cwd and freezes it into the session header at
|
|
538
|
+
// creation. Inbound sessions land in the "Ungrouped" bucket of the
|
|
539
|
+
// session list; they are named `A2A: <summary>` so they're still
|
|
540
|
+
// identifiable there.
|
|
541
|
+
const inboundCwd = inboundSessionCwd(configPath);
|
|
542
|
+
// Seed the model per task from the deployment default selection
|
|
543
|
+
// (ctx.agentDefaultModel) — a fresh session has no model selection,
|
|
544
|
+
// and the persona system prompt fails assembly on an empty
|
|
545
|
+
// `{{model}}` without it. Resolving per task means a Settings model
|
|
546
|
+
// switch is picked up by the next inbound task.
|
|
547
|
+
const dshExec = createDshAgentExecutor(agents, {
|
|
548
|
+
cwd: inboundCwd,
|
|
549
|
+
plugin: 'a2a',
|
|
550
|
+
agentPresets: probeAgentPresets(ctx),
|
|
551
|
+
resolveAgentOptions: () => {
|
|
552
|
+
const sel = probeAgentDefaultModel(ctx)?.currentSelection();
|
|
553
|
+
if (!sel?.model && !sel?.provider) return undefined;
|
|
554
|
+
return { provider: sel.provider, model: sel.model };
|
|
555
|
+
},
|
|
556
|
+
onSessionOpened: ({ session, firstPrompt }) => {
|
|
557
|
+
// Name the session "A2A: <first-prompt summary>" — its prompts
|
|
558
|
+
// carry a plugin source, which DSH's automatic titling skips, so
|
|
559
|
+
// without this it would fall back to the cwd basename.
|
|
560
|
+
probeSessionTitle(ctx)?.rename(session, `A2A: ${summarize(firstPrompt)}`);
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
execute = dshExec;
|
|
564
|
+
disposeExecutor = () => dshExec.disposeAll();
|
|
565
|
+
executorKind = 'dsh-agent';
|
|
566
|
+
logger?.info?.(`[a2a] server executor: DSH agent session per A2A context (cwd ${inboundCwd})`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
476
570
|
const server = new A2AServer(
|
|
477
571
|
{
|
|
478
572
|
baseUrl,
|
|
@@ -481,7 +575,8 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
481
575
|
agentVersion: mergedConfig.agentVersion ?? '0.1.0',
|
|
482
576
|
skills: mergedConfig.skills,
|
|
483
577
|
endpointPath,
|
|
484
|
-
execute
|
|
578
|
+
execute,
|
|
579
|
+
authToken: mergedConfig.authToken,
|
|
485
580
|
onInbound: (facts) => {
|
|
486
581
|
const peerId = dashboard.touchInbound(
|
|
487
582
|
{ headers: facts.headers, source: facts.source },
|
|
@@ -544,17 +639,17 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
544
639
|
}
|
|
545
640
|
const body = await readBody(req);
|
|
546
641
|
if (isStream) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
'
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
642
|
+
const stream = await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
|
|
643
|
+
if (stream.status !== 200) {
|
|
644
|
+
res.writeHead(stream.status, { 'content-type': 'text/plain', ...(stream.headers ?? {}) });
|
|
645
|
+
res.end('Unauthorized');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
553
648
|
res.end();
|
|
554
649
|
return;
|
|
555
650
|
}
|
|
556
651
|
const out = await server.handle(toServerReq(req), body);
|
|
557
|
-
res.writeHead(out.status, { 'content-type': out.contentType });
|
|
652
|
+
res.writeHead(out.status, { 'content-type': out.contentType, ...(out.headers ?? {}) });
|
|
558
653
|
res.end(out.body);
|
|
559
654
|
},
|
|
560
655
|
}),
|
|
@@ -580,6 +675,12 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
580
675
|
agentCardUrl: `${baseUrl}/.well-known/agent-card.json`,
|
|
581
676
|
skills: (server.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description })),
|
|
582
677
|
customExecutor: Boolean(mergedConfig.execute),
|
|
678
|
+
// How inbound tasks are handled: a config-injected executor, this
|
|
679
|
+
// harness's own agent session, or none (refuses until configured).
|
|
680
|
+
executor: executorKind,
|
|
681
|
+
// Whether the inbound endpoint is token-gated. Never expose the token
|
|
682
|
+
// value itself — only whether one is set.
|
|
683
|
+
authConfigured: server.authConfigured,
|
|
583
684
|
});
|
|
584
685
|
dashboard.setHooks({
|
|
585
686
|
setServerEnabled: async (enable) => {
|
|
@@ -595,6 +696,19 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
595
696
|
logger?.info?.('[a2a] A2A server disabled');
|
|
596
697
|
return { ok: true, message: 'A2A server disabled' };
|
|
597
698
|
},
|
|
699
|
+
setServerAuthToken: async (token) => {
|
|
700
|
+
server.setAuthToken(token);
|
|
701
|
+
// Persist to a2a.json (server.authToken) so the gate survives restart.
|
|
702
|
+
const next: PersistedA2AConfig = loadPersistedA2A();
|
|
703
|
+
next.server = { ...(next.server ?? {}), authToken: token && token.length > 0 ? token : undefined };
|
|
704
|
+
const res = savePersistedA2A(next);
|
|
705
|
+
if (!res.ok) {
|
|
706
|
+
logger?.error?.(`[a2a] failed to persist authToken to ${res.path}: ${res.message}`);
|
|
707
|
+
return { ok: false, message: `applied in-memory but failed to save: ${res.message}` };
|
|
708
|
+
}
|
|
709
|
+
logger?.info?.(`[a2a] inbound authToken ${token ? 'set' : 'cleared'} (saved to ${res.path})`);
|
|
710
|
+
return { ok: true, message: token ? '已设置入站鉴权 token' : '已清除入站鉴权 token' };
|
|
711
|
+
},
|
|
598
712
|
serverStatus: () => ({
|
|
599
713
|
ok: true,
|
|
600
714
|
message: JSON.stringify({ enabled, ...serverCard() }),
|
|
@@ -603,6 +717,8 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
603
717
|
|
|
604
718
|
return () => {
|
|
605
719
|
unregisterRoutes();
|
|
720
|
+
// Dispose every live per-context inbound session on unload/teardown.
|
|
721
|
+
void disposeExecutor?.().catch((err) => logger?.error?.(`[a2a] executor dispose failed: ${(err as Error).message}`));
|
|
606
722
|
};
|
|
607
723
|
}, 'a2a: server routes');
|
|
608
724
|
});
|
|
@@ -612,6 +728,95 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
|
|
|
612
728
|
// Wired per-instance inside the client-mode effect above.
|
|
613
729
|
}
|
|
614
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Probe for a usable agent registry WITHOUT taking an inject dependency on it.
|
|
733
|
+
*
|
|
734
|
+
* `ctx.agents` (the registry) can be present while `create()` still rejects —
|
|
735
|
+
* that happens on a bare dsh-base profile that has the agent layer but no
|
|
736
|
+
* agent-loop plugin to register the creation factory. We can't cheaply tell
|
|
737
|
+
* "factory registered" apart from "registry present" without attempting a
|
|
738
|
+
* create, so this returns the registry when it's structurally usable and lets
|
|
739
|
+
* {@link createDshAgentExecutor} degrade to a readable error if a later
|
|
740
|
+
* `create()` rejects.
|
|
741
|
+
*/
|
|
742
|
+
function probeAgentRegistry(ctx: Context): AgentRegistryLike | undefined {
|
|
743
|
+
return probeService(ctx, 'agents', 'create') as AgentRegistryLike | undefined;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Probe for the agent-preset roster service (`ctx.agentPresets`, owned by
|
|
748
|
+
* `@deepseek-ai/dsh-agent-presets`) without an inject dependency. When the
|
|
749
|
+
* deployment composes presets (`dsh-web-app` mounts the roster with a
|
|
750
|
+
* `default: standard`), inbound sessions must be composed from the same
|
|
751
|
+
* preset or they publish against the empty global layer — tools, prompt
|
|
752
|
+
* sections, and skills all absent. Absent rosters keep legacy behavior.
|
|
753
|
+
*/
|
|
754
|
+
function probeAgentPresets(ctx: Context): AgentPresetsLike | undefined {
|
|
755
|
+
return probeService(ctx, 'agentPresets', 'resolve') as AgentPresetsLike | undefined;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Probe for the deployment default-model service without an inject dependency.
|
|
760
|
+
* Returns undefined when the service is absent, so the executor simply creates
|
|
761
|
+
* the agent without a seeded selection — fine for compositions that seed the
|
|
762
|
+
* model another way.
|
|
763
|
+
*/
|
|
764
|
+
function probeAgentDefaultModel(ctx: Context): AgentDefaultModelService | undefined {
|
|
765
|
+
const svc = probeService(ctx, 'agentDefaultModel', 'currentSelection');
|
|
766
|
+
return svc as AgentDefaultModelService | undefined;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Probe `ctx.sessionTitle` (optional). Used to name inbound sessions. */
|
|
770
|
+
function probeSessionTitle(ctx: Context): SessionTitleService | undefined {
|
|
771
|
+
return probeService(ctx, 'sessionTitle', 'rename') as SessionTitleService | undefined;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Read a strict service off the reflection layer and confirm it exposes the
|
|
776
|
+
* named method, without taking an inject dependency. Returns undefined when the
|
|
777
|
+
* service is absent, its fiber inactive, or the method missing.
|
|
778
|
+
*/
|
|
779
|
+
function probeService(ctx: Context, name: string, method: string): unknown {
|
|
780
|
+
try {
|
|
781
|
+
const reflect = (ctx as unknown as { reflect?: { get(n: string, strict?: boolean): unknown } }).reflect;
|
|
782
|
+
const svc = reflect?.get(name, true);
|
|
783
|
+
if (svc && typeof (svc as Record<string, unknown>)[method] === 'function') return svc;
|
|
784
|
+
} catch {
|
|
785
|
+
// reflection unavailable or service resolution threw — treat as absent.
|
|
786
|
+
}
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* One-line title summary from a prompt: first line, whitespace-collapsed, cut
|
|
792
|
+
* to a display-friendly length on a word boundary when possible.
|
|
793
|
+
*/
|
|
794
|
+
function summarize(prompt: string, maxChars = 40): string {
|
|
795
|
+
const text = prompt.replace(/\s+/g, ' ').trim();
|
|
796
|
+
if (text.length <= maxChars) return text || '(empty)';
|
|
797
|
+
const cut = text.slice(0, maxChars);
|
|
798
|
+
const sp = cut.lastIndexOf(' ');
|
|
799
|
+
return `${(sp > maxChars * 0.6 ? cut.slice(0, sp) : cut).trimEnd()}…`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Absolute cwd for inbound per-context sessions: a `.a2a-sessions` subdir of
|
|
804
|
+
* the profile directory (the dir holding `a2a.json`), kept separate from the
|
|
805
|
+
* profile root so inbound sessions don't crowd the user's interactive
|
|
806
|
+
* conversations there. They surface in the session list's "Ungrouped" bucket,
|
|
807
|
+
* named `A2A: <summary>`. Created if missing (persistence writes under it);
|
|
808
|
+
* falls back to cwd on any failure.
|
|
809
|
+
*/
|
|
810
|
+
function inboundSessionCwd(configPath: string): string {
|
|
811
|
+
try {
|
|
812
|
+
const dir = join(dirname(configPath), '.a2a-sessions');
|
|
813
|
+
mkdirSync(dir, { recursive: true });
|
|
814
|
+
return dir;
|
|
815
|
+
} catch {
|
|
816
|
+
return process.cwd();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
615
820
|
/** Drain the request body from a Node IncomingMessage. */
|
|
616
821
|
async function readBody(req: IncomingMessage): Promise<string> {
|
|
617
822
|
return new Promise<string>((resolve, reject) => {
|
package/src/outbound.ts
CHANGED
|
@@ -92,6 +92,25 @@ export async function registerAgentTools(
|
|
|
92
92
|
const skills = card.skills ?? [];
|
|
93
93
|
options.onReady?.({ connectionId, card });
|
|
94
94
|
|
|
95
|
+
// A2A scopes a conversation by `contextId`. To give the LOCAL caller a
|
|
96
|
+
// continuous conversation with this remote agent (cross-call memory), we map
|
|
97
|
+
// the calling DSH agent's session id → a stable contextId, shared across all
|
|
98
|
+
// of this connection's skill-tools (one remote conversation per local agent).
|
|
99
|
+
// Calls with no agent context (e.g. a non-agent caller) share one fallback
|
|
100
|
+
// contextId so they still form a single stable conversation rather than a new
|
|
101
|
+
// remote session per call. Reused for the connection's lifetime.
|
|
102
|
+
const contextByAgent = new Map<string, string>();
|
|
103
|
+
const fallbackContextKey = '\0fallback';
|
|
104
|
+
const contextFor = (agentId: string | undefined): string => {
|
|
105
|
+
const key = agentId ?? fallbackContextKey;
|
|
106
|
+
let ctxId = contextByAgent.get(key);
|
|
107
|
+
if (ctxId === undefined) {
|
|
108
|
+
ctxId = `a2a-out-${crypto.randomUUID()}`;
|
|
109
|
+
contextByAgent.set(key, ctxId);
|
|
110
|
+
}
|
|
111
|
+
return ctxId;
|
|
112
|
+
};
|
|
113
|
+
|
|
95
114
|
let disposers: RegisteredTool[] = [];
|
|
96
115
|
if (options.mapSkills === false || skills.length === 0) {
|
|
97
116
|
// Fall back to a single generic tool for the whole agent.
|
|
@@ -100,13 +119,13 @@ export async function registerAgentTools(
|
|
|
100
119
|
id: 'agent',
|
|
101
120
|
name: options.name,
|
|
102
121
|
description: `${card.name}: ${card.description} (no skills advertised)`,
|
|
103
|
-
}));
|
|
122
|
+
}, contextFor));
|
|
104
123
|
if (dispose) disposers = [{ name, dispose }];
|
|
105
124
|
} else {
|
|
106
125
|
const out: RegisteredTool[] = [];
|
|
107
126
|
for (const skill of skills) {
|
|
108
127
|
const name = skillToToolName(options.name, skill);
|
|
109
|
-
const dispose = tools.register?.(makeTool(name, client, skill));
|
|
128
|
+
const dispose = tools.register?.(makeTool(name, client, skill, contextFor));
|
|
110
129
|
if (dispose) out.push({ name, dispose });
|
|
111
130
|
}
|
|
112
131
|
disposers = out;
|
|
@@ -122,7 +141,12 @@ export async function registerAgentTools(
|
|
|
122
141
|
}));
|
|
123
142
|
}
|
|
124
143
|
|
|
125
|
-
function makeTool(
|
|
144
|
+
function makeTool(
|
|
145
|
+
name: string,
|
|
146
|
+
client: A2AClient,
|
|
147
|
+
skill: Pick<AgentSkill, 'id' | 'name' | 'description' | 'examples'>,
|
|
148
|
+
contextFor: (agentId: string | undefined) => string,
|
|
149
|
+
) {
|
|
126
150
|
const description = [
|
|
127
151
|
skill.description,
|
|
128
152
|
...(skill.examples?.length ? [`Examples: ${skill.examples.join(' | ')}`] : []),
|
|
@@ -147,10 +171,13 @@ function makeTool(name: string, client: A2AClient, skill: Pick<AgentSkill, 'id'
|
|
|
147
171
|
{ type: 'text', text: String(value) },
|
|
148
172
|
],
|
|
149
173
|
},
|
|
150
|
-
async execute(args: { prompt: string }, exec: { signal?: AbortSignal }): Promise<string> {
|
|
174
|
+
async execute(args: { prompt: string }, exec: { signal?: AbortSignal; agent?: { id?: string } }): Promise<string> {
|
|
175
|
+
// Reuse one contextId per calling local agent so multi-turn tool use is a
|
|
176
|
+
// continuous conversation with the remote agent (cross-call memory).
|
|
151
177
|
const message: Message = {
|
|
152
178
|
messageId: crypto.randomUUID(),
|
|
153
179
|
role: Role.USER,
|
|
180
|
+
contextId: contextFor(exec.agent?.id),
|
|
154
181
|
parts: [{ text: args.prompt }],
|
|
155
182
|
};
|
|
156
183
|
try {
|