@ryubyte/dsh-a2a 0.1.5 → 0.2.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/src/dashboard.ts CHANGED
@@ -102,6 +102,20 @@ export interface DashboardControlHooks {
102
102
  serverStatus?: () => ControlResult;
103
103
  /** Runtime-remove an outbound agent by its connection id. */
104
104
  removeAgent?: (connectionId: string) => Promise<ControlResult>;
105
+ /** Update the outbound-server identity (AgentCard) at runtime (persisted). */
106
+ setServerIdentity?: (patch: {
107
+ agentName?: string;
108
+ agentDescription?: string;
109
+ agentVersion?: string;
110
+ baseUrl?: string;
111
+ endpointPath?: string;
112
+ skills?: Array<{ id: string; name: string; description: string; tags?: string[] }>;
113
+ }) => Promise<ControlResult>;
114
+ /** Update a saved outbound agent's advanced fields, reconnecting to apply. */
115
+ updateAgent?: (
116
+ connectionId: string,
117
+ patch: { timeoutMs?: number; mapSkills?: boolean; bearerToken?: string },
118
+ ) => Promise<ControlResult>;
105
119
  }
106
120
 
107
121
  /** Registry of live A2A connections, both directions. */
@@ -311,6 +325,31 @@ export class DashboardRegistry {
311
325
  return this.hooks.serverStatus
312
326
  ? Promise.resolve(this.hooks.serverStatus())
313
327
  : { ok: false, message: 'server-status is not wired (server mode not mounted)' };
328
+ case 'set-server-identity': {
329
+ if (!this.hooks.setServerIdentity) {
330
+ return { ok: false, message: 'set-server-identity is not wired (server not mounted)' };
331
+ }
332
+ return this.hooks.setServerIdentity({
333
+ agentName: payload?.agentName as string | undefined,
334
+ agentDescription: payload?.agentDescription as string | undefined,
335
+ agentVersion: payload?.agentVersion as string | undefined,
336
+ baseUrl: payload?.baseUrl as string | undefined,
337
+ endpointPath: payload?.endpointPath as string | undefined,
338
+ skills: payload?.skills as
339
+ | Array<{ id: string; name: string; description: string; tags?: string[] }>
340
+ | undefined,
341
+ });
342
+ }
343
+ case 'update-agent': {
344
+ if (!this.hooks.updateAgent) {
345
+ return { ok: false, message: 'update-agent is not wired (client mode not mounted)' };
346
+ }
347
+ return this.hooks.updateAgent(target, {
348
+ timeoutMs: typeof payload?.timeoutMs === 'number' ? (payload.timeoutMs as number) : undefined,
349
+ mapSkills: typeof payload?.mapSkills === 'boolean' ? (payload.mapSkills as boolean) : undefined,
350
+ bearerToken: (payload?.bearerToken as string) || undefined,
351
+ });
352
+ }
314
353
  default:
315
354
  return { ok: false, message: `unknown action ${action}` };
316
355
  }
package/src/index.ts CHANGED
@@ -309,8 +309,9 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
309
309
  if (!res.ok) logger?.error?.(`[a2a] failed to save ${res.path}: ${res.message}`);
310
310
  };
311
311
 
312
- // Find an initial (persisted) entry by live connection.
313
- const findInitial = (entry: { name: string; agentCardUrl: string }): { idx: number; item: { name: string; agentCardUrl: string; configured: boolean; enabled: boolean } } | undefined => {
312
+ // Find an initial (persisted) entry by live connection. Returns the full
313
+ // entry (incl. bearerToken/timeoutMs/mapSkills) so callers can edit them.
314
+ const findInitial = (entry: { name: string; agentCardUrl: string }): { idx: number; item: (typeof initialAgents)[number] } | undefined => {
314
315
  const idx = initialAgents.findIndex((a) => a.configured && a.name === entry.name && a.agentCardUrl === entry.agentCardUrl);
315
316
  if (idx < 0) return undefined;
316
317
  return { idx, item: initialAgents[idx] };
@@ -469,6 +470,40 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
469
470
  dashboard.removeAgent(connectionId);
470
471
  return { ok: true, message: `agent ${connectionId} removed` };
471
472
  },
473
+ updateAgent: async (connectionId, patch) => {
474
+ const entry = live.get(connectionId);
475
+ const snap = dashboard.snapshot().outbound.find((o) => o.connectionId === connectionId);
476
+ const name = entry?.name ?? snap?.name;
477
+ const url = entry?.agentCardUrl ?? snap?.agentCardUrl;
478
+ const configured = entry?.configured ?? snap?.configured ?? false;
479
+ if (!name || !url) return { ok: false, message: `connection ${connectionId} not found` };
480
+ const found = findInitial({ name, agentCardUrl: url });
481
+ if (!found) return { ok: false, message: 'only saved (configured) agents can be edited' };
482
+ // Update persisted advanced fields, then reconnect so they bind — these
483
+ // apply at connect()/registerAgentTools time (like enableAgent).
484
+ if (patch.timeoutMs !== undefined) found.item.timeoutMs = patch.timeoutMs;
485
+ if (patch.mapSkills !== undefined) found.item.mapSkills = patch.mapSkills;
486
+ if (patch.bearerToken !== undefined) found.item.bearerToken = patch.bearerToken || undefined;
487
+ persistAgents();
488
+ const item = found.item;
489
+ // A disabled agent: persist only, don't bring it online.
490
+ if (item.enabled === false) {
491
+ return { ok: true, message: `agent ${connectionId} updated (config kept, still disabled)` };
492
+ }
493
+ if (entry) {
494
+ entry.disposeAll();
495
+ live.delete(connectionId);
496
+ }
497
+ dashboard.setAgentState(connectionId, 'reconnecting');
498
+ const newId = await connect(item.name, item.agentCardUrl, item.configured, {
499
+ bearerToken: item.bearerToken,
500
+ timeoutMs: item.timeoutMs,
501
+ mapSkills: item.mapSkills,
502
+ });
503
+ if (!newId) return { ok: false, message: `update of ${connectionId} failed to reconnect` };
504
+ dashboard.removeAgent(connectionId);
505
+ return { ok: true, message: `agent ${connectionId} updated as ${newId} (saved to ${configPath})` };
506
+ },
472
507
  });
473
508
 
474
509
  ctx.effect(() => {
@@ -501,7 +536,15 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
501
536
  }
502
537
 
503
538
  // ── server mode: expose DSH as an A2A agent ───────────────────────────────
504
- if (wantsServer) {
539
+ // The server effect is ALWAYS mounted (not gated on `wantsServer`), so its
540
+ // control hooks (serverStatus / setServerEnabled / setServerIdentity) are
541
+ // wired even in a client-only profile — letting the UI create and enable the
542
+ // A2A server at runtime with no restart and no hand-edit of a2a.json. Route
543
+ // registration itself is gated on `enabled` (initialized from `wantsServer`),
544
+ // so a profile that didn't ask to serve starts with the routes off. Building
545
+ // the A2AServer + probing the executor is side-effect-free (no sessions are
546
+ // created until an inbound task actually arrives).
547
+ {
505
548
  const store = new TaskStore();
506
549
  const taskPeer = new Map<string, string>();
507
550
  const pendingSettled = new Set<string>();
@@ -513,6 +556,24 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
513
556
  // to 0.0.0.0, advertise a reachable LAN address, not loopback.
514
557
  const baseUrl = mergedConfig.baseUrl ?? inferBaseUrl(webServer.host, webServer.port);
515
558
  const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
559
+ // Surface a stale pinned baseUrl: if the config pins a port that does
560
+ // not match the real listen port, the advertised AgentCard URL is
561
+ // unreachable. We don't silently rewrite an explicit pin (it may point
562
+ // at a reverse proxy), but we warn so the mismatch is diagnosable —
563
+ // clearing baseUrl in the UI returns it to auto-inference.
564
+ if (mergedConfig.baseUrl) {
565
+ try {
566
+ const pinnedPort = new URL(mergedConfig.baseUrl).port;
567
+ if (pinnedPort && Number(pinnedPort) !== webServer.port) {
568
+ logger?.warn?.(
569
+ `[a2a] pinned baseUrl ${mergedConfig.baseUrl} port ${pinnedPort} != listen port ${webServer.port}; ` +
570
+ `remote callers may be unreachable. Clear baseUrl in settings to auto-infer.`,
571
+ );
572
+ }
573
+ } catch {
574
+ // malformed pinned URL — ignore (the card just advertises it as-is).
575
+ }
576
+ }
516
577
 
517
578
  // ── executor precedence ───────────────────────────────────────────────
518
579
  // 1. explicit `execute` (config injection) → use it
@@ -605,7 +666,10 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
605
666
  );
606
667
 
607
668
  // ── runtime-enabled route set ──────────────────────────────────────
608
- let enabled = true;
669
+ // Start serving only if this profile asked to (mode server/both or an
670
+ // explicit server.enabled). A client-only profile mounts the effect but
671
+ // keeps routes off until the UI enables the server.
672
+ let enabled = wantsServer;
609
673
  let disposers: Array<() => void> = [];
610
674
 
611
675
  const registerRoutes = (): void => {
@@ -660,20 +724,28 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
660
724
  disposers = [];
661
725
  };
662
726
 
663
- registerRoutes();
664
- logger?.info?.(
665
- `[a2a] A2A server mounted: AgentCard at ${baseUrl}/.well-known/agent-card.json, JSON-RPC at ${server.endpointPath}`,
666
- );
727
+ if (enabled) {
728
+ registerRoutes();
729
+ logger?.info?.(
730
+ `[a2a] A2A server mounted: AgentCard at ${baseUrl}/.well-known/agent-card.json, JSON-RPC at ${server.endpointPath}`,
731
+ );
732
+ } else {
733
+ logger?.info?.('[a2a] A2A server ready but not serving (enable via settings)');
734
+ }
667
735
 
668
736
  // ── serve panel hooks ──────────────────────────────────────────────
737
+ // Read live values off the server: identity edits mutate the card in
738
+ // place, so a captured `const baseUrl` would go stale. `endpointPath`
739
+ // and each skill's `tags` are surfaced so the edit form can prefill.
669
740
  const serverCard = (): Record<string, unknown> => ({
670
- baseUrl,
741
+ baseUrl: server.getBaseUrl(),
671
742
  agentName: server.card.name,
672
743
  agentDescription: server.card.description,
673
744
  agentVersion: server.card.version,
674
- endpoint: `${baseUrl}${server.endpointPath}`,
675
- agentCardUrl: `${baseUrl}/.well-known/agent-card.json`,
676
- skills: (server.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description })),
745
+ endpointPath: server.endpointPath,
746
+ endpoint: `${server.getBaseUrl()}${server.endpointPath}`,
747
+ agentCardUrl: `${server.getBaseUrl()}/.well-known/agent-card.json`,
748
+ skills: (server.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description, tags: s.tags })),
677
749
  customExecutor: Boolean(mergedConfig.execute),
678
750
  // How inbound tasks are handled: a config-injected executor, this
679
751
  // harness's own agent session, or none (refuses until configured).
@@ -689,12 +761,61 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
689
761
  registerRoutes();
690
762
  enabled = true;
691
763
  logger?.info?.('[a2a] A2A server enabled');
692
- return { ok: true, message: 'A2A server enabled' };
764
+ } else {
765
+ unregisterRoutes();
766
+ enabled = false;
767
+ logger?.info?.('[a2a] A2A server disabled');
768
+ }
769
+ // Persist server.enabled so the choice survives restart (mergePersisted
770
+ // maps server.enabled → serverEnabled → wantsServer on next boot).
771
+ const next: PersistedA2AConfig = loadPersistedA2A();
772
+ next.server = { ...(next.server ?? {}), enabled };
773
+ const res = savePersistedA2A(next);
774
+ if (!res.ok) {
775
+ logger?.error?.(`[a2a] failed to persist server.enabled to ${res.path}: ${res.message}`);
776
+ return { ok: false, message: `applied in-memory but failed to save: ${res.message}` };
777
+ }
778
+ return { ok: true, message: enable ? 'A2A server enabled' : 'A2A server disabled' };
779
+ },
780
+ setServerIdentity: async (patch) => {
781
+ // baseUrl semantics: undefined = "not touched"; "" = "clear pin,
782
+ // return to auto-inference"; non-empty = explicit pin (reverse
783
+ // proxy / public domain). Empty must NOT reach updateCard (which
784
+ // would set the advertised URL to ""), so handle it here where the
785
+ // real listen address (webServer.host/port) is available.
786
+ const clearBaseUrl = patch.baseUrl === '';
787
+ const cardPatch = clearBaseUrl ? { ...patch, baseUrl: undefined } : patch;
788
+ const { endpointChanged } = server.updateCard(cardPatch);
789
+ if (clearBaseUrl) {
790
+ // Re-derive from the actual listen address and re-advertise.
791
+ server.setBaseUrl(inferBaseUrl(webServer.host, webServer.port));
792
+ }
793
+ // The JSON-RPC path moved: re-register on the new path, but only
794
+ // while serving (disabled → routes get the new path on next enable).
795
+ if (endpointChanged && enabled) {
796
+ unregisterRoutes();
797
+ registerRoutes();
798
+ }
799
+ // Persist the provided fields to a2a.json server.{...}.
800
+ const next: PersistedA2AConfig = loadPersistedA2A();
801
+ const s = { ...(next.server ?? {}) };
802
+ if (patch.agentName !== undefined) s.agentName = patch.agentName;
803
+ if (patch.agentDescription !== undefined) s.agentDescription = patch.agentDescription;
804
+ if (patch.agentVersion !== undefined) s.agentVersion = patch.agentVersion;
805
+ // Clear the persisted pin so it stays auto on restart; a non-empty
806
+ // value pins it; undefined leaves the existing setting untouched.
807
+ if (clearBaseUrl) delete s.baseUrl;
808
+ else if (patch.baseUrl !== undefined) s.baseUrl = patch.baseUrl;
809
+ if (patch.endpointPath !== undefined) s.endpointPath = patch.endpointPath;
810
+ if (patch.skills !== undefined) s.skills = patch.skills;
811
+ next.server = s;
812
+ const res = savePersistedA2A(next);
813
+ if (!res.ok) {
814
+ logger?.error?.(`[a2a] failed to persist server identity to ${res.path}: ${res.message}`);
815
+ return { ok: false, message: `applied in-memory but failed to save: ${res.message}` };
693
816
  }
694
- unregisterRoutes();
695
- enabled = false;
696
- logger?.info?.('[a2a] A2A server disabled');
697
- return { ok: true, message: 'A2A server disabled' };
817
+ logger?.info?.(`[a2a] server identity updated (saved to ${res.path})`);
818
+ return { ok: true, message: `已更新对外服务 (saved to ${res.path})` };
698
819
  },
699
820
  setServerAuthToken: async (token) => {
700
821
  server.setAuthToken(token);
package/src/server.ts CHANGED
@@ -32,6 +32,20 @@ import {
32
32
 
33
33
  export interface ServerSkill extends AgentSkill {}
34
34
 
35
+ /**
36
+ * Runtime-editable slice of the advertised AgentCard identity. Every field is
37
+ * optional; only the provided ones change. Applied in place by
38
+ * {@link A2AServer.updateCard} (mirrors setBaseUrl/setAuthToken).
39
+ */
40
+ export interface CardIdentityPatch {
41
+ agentName?: string;
42
+ agentDescription?: string;
43
+ agentVersion?: string;
44
+ baseUrl?: string;
45
+ endpointPath?: string;
46
+ skills?: ServerSkill[];
47
+ }
48
+
35
49
  export interface ServerOptions {
36
50
  /** Public base URL (scheme + host + optional prefix) where this DSH is exposed. */
37
51
  baseUrl: string;
@@ -155,7 +169,8 @@ export class A2AServer {
155
169
  readonly options: ServerOptions;
156
170
  readonly store: TaskStore;
157
171
  readonly card: AgentCard;
158
- readonly endpointPath: string;
172
+ /** JSON-RPC route path. Mutable: `updateCard` can move it at runtime. */
173
+ endpointPath: string;
159
174
  private execute: TaskExecutor;
160
175
  private listeners = new Set<(ev: StreamResponse) => void>();
161
176
  private baseUrl: string;
@@ -181,6 +196,16 @@ export class A2AServer {
181
196
  */
182
197
  setBaseUrl(baseUrl: string): void {
183
198
  this.baseUrl = baseUrl.replace(/\/$/, '');
199
+ this.rebuildInterfaceUrls();
200
+ }
201
+
202
+ /** The base URL currently advertised in the AgentCard. */
203
+ getBaseUrl(): string {
204
+ return this.baseUrl;
205
+ }
206
+
207
+ /** Rebuild the advertised interface URL(s) from the current baseUrl + path. */
208
+ private rebuildInterfaceUrls(): void {
184
209
  this.card.supportedInterfaces = this.card.supportedInterfaces.map((iface) => ({
185
210
  ...iface,
186
211
  url: `${this.baseUrl}${this.endpointPath}`,
@@ -211,6 +236,47 @@ export class A2AServer {
211
236
  return Boolean(this.options.authToken);
212
237
  }
213
238
 
239
+ /**
240
+ * Update the advertised identity (name/description/version/baseUrl/
241
+ * endpointPath/skills) at runtime, in place. Mirrors setBaseUrl/setAuthToken:
242
+ * mutates `this.options` and the existing `this.card` object's properties —
243
+ * never reassigns `this.card`, so the route handler closure that reads
244
+ * `server.card` per request keeps seeing the live card.
245
+ *
246
+ * Returns `{ endpointChanged }`: when the JSON-RPC path moved, the caller
247
+ * (which owns route registration) must re-register routes on the new path —
248
+ * `updateCard` only mutates the advertised URL, not the live HTTP routes.
249
+ */
250
+ updateCard(patch: CardIdentityPatch): { endpointChanged: boolean } {
251
+ let endpointChanged = false;
252
+ if (patch.agentName !== undefined) {
253
+ this.options.agentName = patch.agentName;
254
+ this.card.name = patch.agentName;
255
+ }
256
+ if (patch.agentDescription !== undefined) {
257
+ this.options.agentDescription = patch.agentDescription;
258
+ this.card.description = patch.agentDescription;
259
+ }
260
+ if (patch.agentVersion !== undefined) {
261
+ this.options.agentVersion = patch.agentVersion;
262
+ this.card.version = patch.agentVersion;
263
+ }
264
+ if (patch.skills !== undefined) {
265
+ this.options.skills = patch.skills;
266
+ this.card.skills = patch.skills;
267
+ }
268
+ if (patch.endpointPath !== undefined && patch.endpointPath !== this.endpointPath) {
269
+ this.endpointPath = patch.endpointPath || '/a2a';
270
+ endpointChanged = true;
271
+ }
272
+ if (patch.baseUrl !== undefined) {
273
+ this.baseUrl = patch.baseUrl.replace(/\/$/, '');
274
+ }
275
+ // Any baseUrl/endpointPath change invalidates the advertised interface URL.
276
+ if (patch.baseUrl !== undefined || endpointChanged) this.rebuildInterfaceUrls();
277
+ return { endpointChanged };
278
+ }
279
+
214
280
  private buildCard(): AgentCard {
215
281
  const iface: AgentInterface = {
216
282
  url: `${this.baseUrl.replace(/\/$/, '')}${this.endpointPath}`,