@ryubyte/dsh-a2a 0.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 +224 -0
- package/README.zh-CN.md +196 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +574 -0
- package/lib/index.js +1896 -0
- package/lib/types/a2a-config.d.ts +107 -0
- package/lib/types/a2a-config.d.ts.map +1 -0
- package/lib/types/card.d.ts +40 -0
- package/lib/types/card.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client.d.ts +52 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/dashboard.d.ts +144 -0
- package/lib/types/dashboard.d.ts.map +1 -0
- package/lib/types/errors.d.ts +24 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/index.d.ts +89 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/jsonrpc.d.ts +40 -0
- package/lib/types/jsonrpc.d.ts.map +1 -0
- package/lib/types/outbound.d.ts +45 -0
- package/lib/types/outbound.d.ts.map +1 -0
- package/lib/types/protocol.d.ts +330 -0
- package/lib/types/protocol.d.ts.map +1 -0
- package/lib/types/server.d.ts +142 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +89 -0
- package/src/a2a-config.ts +201 -0
- package/src/card.ts +143 -0
- package/src/client/index.ts +613 -0
- package/src/client.ts +141 -0
- package/src/dashboard.ts +350 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +659 -0
- package/src/jsonrpc.ts +185 -0
- package/src/outbound.ts +186 -0
- package/src/protocol.ts +308 -0
- package/src/server.ts +467 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent2Agent) Protocol v1.0 — outbound client.
|
|
3
|
+
*
|
|
4
|
+
* A thin client that discovers an agent via its AgentCard and speaks the
|
|
5
|
+
* JSONRPC binding: SendMessage (sync/async), SendStreamingMessage,
|
|
6
|
+
* GetTask, CancelTask, ListTasks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fetchAgentCard, pickInterface } from './card.js';
|
|
10
|
+
import type { AgentCard, Message, SendMessageConfiguration, SendMessageResponse, StreamResponse, Task } from './protocol.js';
|
|
11
|
+
import { TaskState, isTerminal } from './protocol.js';
|
|
12
|
+
import { JsonRpcClient, type JsonRpcClientOptions, type JsonRpcCallOptions } from './jsonrpc.js';
|
|
13
|
+
import { A2AError } from './errors.js';
|
|
14
|
+
|
|
15
|
+
export interface A2AClientOptions extends JsonRpcClientOptions {
|
|
16
|
+
/** Prefer a specific protocol binding. Default 'JSONRPC'. */
|
|
17
|
+
preferredBinding?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WaitForTaskOptions {
|
|
21
|
+
/** Poll interval in ms. Default 500. */
|
|
22
|
+
intervalMs?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A2A client bound to one remote agent. */
|
|
27
|
+
export class A2AClient {
|
|
28
|
+
readonly card: AgentCard;
|
|
29
|
+
private readonly rpc: JsonRpcClient;
|
|
30
|
+
readonly endpointUrl: string;
|
|
31
|
+
readonly tenant?: string;
|
|
32
|
+
|
|
33
|
+
static async connect(
|
|
34
|
+
agentCardUrlOrBase: string,
|
|
35
|
+
options: A2AClientOptions = {},
|
|
36
|
+
): Promise<A2AClient> {
|
|
37
|
+
const card = await fetchAgentCard(agentCardUrlOrBase, options);
|
|
38
|
+
return new A2AClient(card, options);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(card: AgentCard, options: A2AClientOptions = {}) {
|
|
42
|
+
this.card = card;
|
|
43
|
+
const { iface, url } = pickInterface(card, options.preferredBinding);
|
|
44
|
+
this.endpointUrl = url;
|
|
45
|
+
this.tenant = iface.tenant;
|
|
46
|
+
this.rpc = new JsonRpcClient(url, options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Send a message; returns a Task or Message per the server's choice. */
|
|
50
|
+
async sendMessage(
|
|
51
|
+
message: Message,
|
|
52
|
+
configuration?: SendMessageConfiguration,
|
|
53
|
+
options: JsonRpcCallOptions = {},
|
|
54
|
+
): Promise<SendMessageResponse> {
|
|
55
|
+
return this.rpc.call<SendMessageResponse>('SendMessage', {
|
|
56
|
+
message,
|
|
57
|
+
configuration,
|
|
58
|
+
...(this.tenant ? { tenant: this.tenant } : {}),
|
|
59
|
+
}, options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Send a message over a stream, yielding all StreamResponse events. */
|
|
63
|
+
streamMessage(
|
|
64
|
+
message: Message,
|
|
65
|
+
configuration: SendMessageConfiguration = {},
|
|
66
|
+
options: JsonRpcCallOptions = {},
|
|
67
|
+
): AsyncGenerator<StreamResponse> {
|
|
68
|
+
return this.rpc.stream<StreamResponse>('SendStreamingMessage', {
|
|
69
|
+
message,
|
|
70
|
+
configuration,
|
|
71
|
+
...(this.tenant ? { tenant: this.tenant } : {}),
|
|
72
|
+
}, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getTask(id: string, historyLength?: number, options: JsonRpcCallOptions = {}): Promise<Task> {
|
|
76
|
+
return this.rpc.call<Task>('GetTask', {
|
|
77
|
+
id,
|
|
78
|
+
...(historyLength !== undefined ? { historyLength } : {}),
|
|
79
|
+
...(this.tenant ? { tenant: this.tenant } : {}),
|
|
80
|
+
}, options);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async listTasks(params: {
|
|
84
|
+
contextId?: string;
|
|
85
|
+
status?: TaskState;
|
|
86
|
+
pageSize?: number;
|
|
87
|
+
pageToken?: string;
|
|
88
|
+
includeArtifacts?: boolean;
|
|
89
|
+
} = {}, options: JsonRpcCallOptions = {}): Promise<{
|
|
90
|
+
tasks: Task[];
|
|
91
|
+
nextPageToken: string;
|
|
92
|
+
pageSize: number;
|
|
93
|
+
totalSize: number;
|
|
94
|
+
}> {
|
|
95
|
+
return this.rpc.call('ListTasks', { ...params, ...(this.tenant ? { tenant: this.tenant } : {}) }, options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async cancelTask(id: string, options: JsonRpcCallOptions = {}): Promise<Task> {
|
|
99
|
+
return this.rpc.call<Task>('CancelTask', {
|
|
100
|
+
id,
|
|
101
|
+
...(this.tenant ? { tenant: this.tenant } : {}),
|
|
102
|
+
}, options);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Send a message and wait (polling) until the created task reaches a
|
|
107
|
+
* terminal state. Errors if the task fails or is rejected.
|
|
108
|
+
*/
|
|
109
|
+
async sendAndWait(
|
|
110
|
+
message: Message,
|
|
111
|
+
configuration: SendMessageConfiguration = {},
|
|
112
|
+
options: JsonRpcCallOptions & WaitForTaskOptions = {},
|
|
113
|
+
): Promise<Task> {
|
|
114
|
+
const resp = await this.sendMessage(message, { ...configuration, returnImmediately: true }, options);
|
|
115
|
+
if ('message' in resp && resp.message) {
|
|
116
|
+
// Stateless agent: no task to track; synthesize a completed task.
|
|
117
|
+
return {
|
|
118
|
+
id: 'message',
|
|
119
|
+
status: { state: TaskState.COMPLETED, timestamp: new Date().toISOString() },
|
|
120
|
+
artifacts: [{ artifactId: 'message', parts: resp.message.parts }],
|
|
121
|
+
history: [resp.message],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const task = 'task' in resp && resp.task ? resp.task : undefined;
|
|
125
|
+
if (!task) throw new A2AError(-32602, 'SendMessage returned neither a Task nor a Message');
|
|
126
|
+
let t = task;
|
|
127
|
+
for (;;) {
|
|
128
|
+
if (isTerminal(t.status.state as TaskState)) return t;
|
|
129
|
+
// INPUT_REQUIRED / AUTH_REQUIRED are NOT hard errors: the remote agent
|
|
130
|
+
// is asking for clarification (or credentials). Return the task so the
|
|
131
|
+
// caller can surface `status.message` to the model and let it respond,
|
|
132
|
+
// instead of throwing and stalling the tool call.
|
|
133
|
+
if (t.status.state === TaskState.INPUT_REQUIRED || t.status.state === TaskState.AUTH_REQUIRED) {
|
|
134
|
+
return t;
|
|
135
|
+
}
|
|
136
|
+
if (options.signal?.aborted) throw new A2AError(408, 'Task wait aborted');
|
|
137
|
+
await new Promise((r) => setTimeout(r, options.intervalMs ?? 500));
|
|
138
|
+
t = await this.getTask(t.id, 0, options);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — connection dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Tracks both directions of A2A connectivity so the operator (and the
|
|
5
|
+
* settings dashboard) can see:
|
|
6
|
+
*
|
|
7
|
+
* - **inbound** — which remote peers are talking to THIS DSH's A2A server
|
|
8
|
+
* (peer label, source address, last activity, live tasks).
|
|
9
|
+
* - **outbound** — which remote agents THIS DSH is connected to via the
|
|
10
|
+
* client mode (agent name, AgentCard URL, skill count,
|
|
11
|
+
* connection state) with reconnect/close controls.
|
|
12
|
+
*
|
|
13
|
+
* The registry is process-local and purely observational: entries never
|
|
14
|
+
* affect protocol behavior by themselves. Control operations
|
|
15
|
+
* (`reconnectPeer`, `closePeer`, `reconnectAgent`, `closeAgent`) are wired
|
|
16
|
+
* by the plugin entry to the real connection owners.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { A2AClient } from './client.js';
|
|
20
|
+
|
|
21
|
+
export interface InboundPeer {
|
|
22
|
+
/** Stable per-process id (random). */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Short display label derived from the request. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Source socket address, when visible ("127.0.0.1:54321"). */
|
|
27
|
+
source?: string;
|
|
28
|
+
/** First-seen ISO timestamp. */
|
|
29
|
+
firstSeen: string;
|
|
30
|
+
/** Last-activity ISO timestamp. */
|
|
31
|
+
lastSeen: string;
|
|
32
|
+
/** Number of tasks this peer has created/continued. */
|
|
33
|
+
taskCount: number;
|
|
34
|
+
/** Ids of tasks still running for this peer. */
|
|
35
|
+
activeTaskIds: string[];
|
|
36
|
+
/** True while at least one streaming (SSE) connection is open. */
|
|
37
|
+
streaming: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OutboundAgent {
|
|
41
|
+
/** Stable per-process id. */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Namespace used for tool names ("research" → a2a__research__<skill>). */
|
|
44
|
+
name: string;
|
|
45
|
+
/** AgentCard URL the client was connected to. */
|
|
46
|
+
agentCardUrl: string;
|
|
47
|
+
/** Remote agent display name (from the AgentCard). */
|
|
48
|
+
agentName?: string;
|
|
49
|
+
/** Number of skills mapped to tools. */
|
|
50
|
+
skillCount: number;
|
|
51
|
+
/** Number of tools registered for this agent. */
|
|
52
|
+
toolCount: number;
|
|
53
|
+
/** Connection state. */
|
|
54
|
+
state: 'connected' | 'disconnected' | 'reconnecting';
|
|
55
|
+
/** False when this agent is configured but disabled in a2a.json (no live connection). */
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
/** Last-activity ISO timestamp. */
|
|
58
|
+
lastSeen: string;
|
|
59
|
+
/** Per-process connection id assigned by the outbound registry. */
|
|
60
|
+
connectionId?: string;
|
|
61
|
+
/** True when this connection came from the profile config (vs runtime-added). */
|
|
62
|
+
configured?: boolean;
|
|
63
|
+
/** Skills advertised by the remote AgentCard (for the detail view). */
|
|
64
|
+
skills?: Array<{ id: string; name: string; description?: string; tags?: string[] }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DashboardSnapshot {
|
|
68
|
+
inbound: InboundPeer[];
|
|
69
|
+
outbound: OutboundAgent[];
|
|
70
|
+
/** Epoch ms of the snapshot. */
|
|
71
|
+
at: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ControlResult {
|
|
75
|
+
ok: boolean;
|
|
76
|
+
message: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How the plugin entry wires control operations to connection owners. */
|
|
80
|
+
export interface DashboardControlHooks {
|
|
81
|
+
/** Reconnect an outbound agent by its connection id. */
|
|
82
|
+
reconnectAgent?: (connectionId: string) => Promise<ControlResult>;
|
|
83
|
+
/** Disable (disconnect + persist enabled:false) an outbound agent without removing it. */
|
|
84
|
+
disableAgent?: (connectionId: string) => Promise<ControlResult>;
|
|
85
|
+
/** Enable a disabled outbound agent (persist enabled:true and reconnect). */
|
|
86
|
+
enableAgent?: (connectionId: string) => Promise<ControlResult>;
|
|
87
|
+
/** Close an outbound agent by its connection id. */
|
|
88
|
+
closeAgent?: (connectionId: string) => Promise<ControlResult>;
|
|
89
|
+
/** Close (sever) an inbound peer by its id. */
|
|
90
|
+
closePeer?: (peerId: string) => Promise<ControlResult>;
|
|
91
|
+
/** Reconnect is not meaningful for an inbound peer — rejects unless overridden. */
|
|
92
|
+
reconnectPeer?: (peerId: string) => Promise<ControlResult>;
|
|
93
|
+
/** Runtime-add an outbound agent (visual config). Returns the new connection id. */
|
|
94
|
+
addAgent?: (name: string, agentCardUrl: string) => Promise<ControlResult>;
|
|
95
|
+
/** Discover an Agent Card without connecting (for the import preview). */
|
|
96
|
+
discoverAgent?: (agentCardUrl: string) => Promise<ControlResult>;
|
|
97
|
+
/** Enable/disable the inbound A2A server (serve routes) at runtime. */
|
|
98
|
+
setServerEnabled?: (enabled: boolean) => Promise<ControlResult>;
|
|
99
|
+
/** Read current inbound server status (for the serve panel). */
|
|
100
|
+
serverStatus?: () => ControlResult;
|
|
101
|
+
/** Runtime-remove an outbound agent by its connection id. */
|
|
102
|
+
removeAgent?: (connectionId: string) => Promise<ControlResult>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Registry of live A2A connections, both directions. */
|
|
106
|
+
export class DashboardRegistry {
|
|
107
|
+
private peers = new Map<string, InboundPeer>();
|
|
108
|
+
private agents = new Map<string, OutboundAgent>();
|
|
109
|
+
private hooks: DashboardControlHooks = {};
|
|
110
|
+
private seq = 0;
|
|
111
|
+
|
|
112
|
+
setHooks(hooks: DashboardControlHooks): void {
|
|
113
|
+
this.hooks = { ...this.hooks, ...hooks };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── inbound ───────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** Record one inbound request (or stream open). Returns the peer id. */
|
|
119
|
+
touchInbound(req: { headers?: Record<string, string>; source?: string }, taskIds: string[] = []): string {
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const ua = req.headers?.['user-agent'] ?? req.headers?.['User-Agent'] ?? 'unknown';
|
|
122
|
+
const remote = req.source;
|
|
123
|
+
const label = peerLabel(ua, remote);
|
|
124
|
+
|
|
125
|
+
// Reuse an existing peer when the same client identity keeps talking.
|
|
126
|
+
// `source` is a socket address (`ip:port`); the port changes on every new
|
|
127
|
+
// HTTP connection, so it cannot be the dedup key — otherwise every request
|
|
128
|
+
// from the same agent would spawn a new row. Dedup on label + host IP and
|
|
129
|
+
// refresh the displayed socket address to the latest one.
|
|
130
|
+
const host = remote?.replace(/:\d+$/, '');
|
|
131
|
+
const key = `${label}|${host ?? ''}`;
|
|
132
|
+
let peer: InboundPeer | undefined;
|
|
133
|
+
for (const p of this.peers.values()) {
|
|
134
|
+
const pHost = p.source?.replace(/:\d+$/, '') ?? '';
|
|
135
|
+
if (`${p.label}|${pHost}` === key) {
|
|
136
|
+
peer = p;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!peer) {
|
|
141
|
+
const id = `in-${++this.seq}`;
|
|
142
|
+
peer = { id, label, source: remote, firstSeen: now, lastSeen: now, taskCount: 0, activeTaskIds: [], streaming: false };
|
|
143
|
+
this.peers.set(id, peer);
|
|
144
|
+
}
|
|
145
|
+
peer.source = remote ?? peer.source;
|
|
146
|
+
peer.lastSeen = now;
|
|
147
|
+
if (taskIds.length) {
|
|
148
|
+
peer.taskCount += taskIds.length;
|
|
149
|
+
for (const t of taskIds) if (!peer.activeTaskIds.includes(t)) peer.activeTaskIds.push(t);
|
|
150
|
+
}
|
|
151
|
+
return peer.id;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Mark a task finished for a peer (drops it from the active list). */
|
|
155
|
+
finishTask(peerId: string, taskId: string): void {
|
|
156
|
+
const peer = this.peers.get(peerId);
|
|
157
|
+
if (!peer) return;
|
|
158
|
+
peer.activeTaskIds = peer.activeTaskIds.filter((t) => t !== taskId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Mark a peer's streaming flag. */
|
|
162
|
+
setStreaming(peerId: string, streaming: boolean): void {
|
|
163
|
+
const peer = this.peers.get(peerId);
|
|
164
|
+
if (peer) peer.streaming = streaming;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Remove an inbound peer (the "关闭" action severs tracking). */
|
|
168
|
+
removePeer(id: string): void {
|
|
169
|
+
this.peers.delete(id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── outbound ──────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** Register or refresh an outbound agent connection. */
|
|
175
|
+
upsertAgent(agent: Omit<OutboundAgent, 'id' | 'lastSeen'>): string {
|
|
176
|
+
const now = new Date().toISOString();
|
|
177
|
+
// Key by connectionId when present (the same agent can be re-created).
|
|
178
|
+
let id: string | undefined;
|
|
179
|
+
if (agent.connectionId) {
|
|
180
|
+
for (const [k, v] of this.agents) {
|
|
181
|
+
if (v.connectionId === agent.connectionId) {
|
|
182
|
+
id = k;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!id) {
|
|
188
|
+
id = `out-${++this.seq}`;
|
|
189
|
+
this.agents.set(id, { ...agent, id, lastSeen: now });
|
|
190
|
+
} else {
|
|
191
|
+
const existing = this.agents.get(id)!;
|
|
192
|
+
// Merge: zero/absent optional fields keep their existing value (a
|
|
193
|
+
// refresh reporting only toolCount must not wipe skillCount).
|
|
194
|
+
this.agents.set(id, {
|
|
195
|
+
...existing,
|
|
196
|
+
...agent,
|
|
197
|
+
skillCount: agent.skillCount > 0 ? agent.skillCount : existing.skillCount,
|
|
198
|
+
toolCount: agent.toolCount > 0 ? agent.toolCount : existing.toolCount,
|
|
199
|
+
agentName: agent.agentName ?? existing.agentName,
|
|
200
|
+
skills: agent.skills ? agent.skills : existing.skills,
|
|
201
|
+
state: agent.state ?? existing.state,
|
|
202
|
+
id,
|
|
203
|
+
lastSeen: now,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return id;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Remove an outbound agent by id or connectionId. */
|
|
210
|
+
removeAgent(connectionId: string): boolean {
|
|
211
|
+
for (const [k, v] of this.agents) {
|
|
212
|
+
if (v.connectionId === connectionId || k === connectionId) {
|
|
213
|
+
this.agents.delete(k);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Update one agent's state. */
|
|
221
|
+
setAgentState(connectionId: string, state: OutboundAgent['state']): void {
|
|
222
|
+
for (const v of this.agents.values()) {
|
|
223
|
+
if (v.connectionId === connectionId) {
|
|
224
|
+
v.state = state;
|
|
225
|
+
v.lastSeen = new Date().toISOString();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Touch one agent's last activity. */
|
|
231
|
+
touchAgent(connectionId: string): void {
|
|
232
|
+
for (const v of this.agents.values()) {
|
|
233
|
+
if (v.connectionId === connectionId) v.lastSeen = new Date().toISOString();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── snapshot + control ────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
snapshot(): DashboardSnapshot {
|
|
240
|
+
return {
|
|
241
|
+
inbound: [...this.peers.values()].map((p) => ({ ...p, activeTaskIds: [...p.activeTaskIds] })),
|
|
242
|
+
outbound: [...this.agents.values()].map((a) => ({ ...a })),
|
|
243
|
+
at: Date.now(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async control(action: string, target: string, payload?: Record<string, unknown>): Promise<ControlResult> {
|
|
248
|
+
switch (action) {
|
|
249
|
+
case 'reconnect-agent':
|
|
250
|
+
return this.hooks.reconnectAgent
|
|
251
|
+
? this.hooks.reconnectAgent(target)
|
|
252
|
+
: { ok: false, message: 'reconnect-agent is not wired' };
|
|
253
|
+
case 'close-agent':
|
|
254
|
+
return this.hooks.closeAgent
|
|
255
|
+
? this.hooks.closeAgent(target)
|
|
256
|
+
: { ok: false, message: 'close-agent is not wired' };
|
|
257
|
+
case 'disable-agent':
|
|
258
|
+
return this.hooks.disableAgent
|
|
259
|
+
? this.hooks.disableAgent(target)
|
|
260
|
+
: { ok: false, message: 'disable-agent is not wired (client mode not mounted)' };
|
|
261
|
+
case 'enable-agent':
|
|
262
|
+
return this.hooks.enableAgent
|
|
263
|
+
? this.hooks.enableAgent(target)
|
|
264
|
+
: { ok: false, message: 'enable-agent is not wired (client mode not mounted)' };
|
|
265
|
+
case 'reconnect-peer':
|
|
266
|
+
return this.hooks.reconnectPeer
|
|
267
|
+
? this.hooks.reconnectPeer(target)
|
|
268
|
+
: { ok: false, message: 'reconnect-peer is not supported' };
|
|
269
|
+
case 'close-peer':
|
|
270
|
+
return Promise.resolve(this.closePeerLocal(target));
|
|
271
|
+
case 'discover-agent': {
|
|
272
|
+
const url = (payload?.agentCardUrl as string) ?? target;
|
|
273
|
+
if (!url) return { ok: false, message: 'discover-agent requires agentCardUrl' };
|
|
274
|
+
return this.hooks.discoverAgent
|
|
275
|
+
? this.hooks.discoverAgent(url)
|
|
276
|
+
: { ok: false, message: 'discover-agent is not wired' };
|
|
277
|
+
}
|
|
278
|
+
case 'add-agent': {
|
|
279
|
+
const name = (payload?.name as string) ?? target;
|
|
280
|
+
const url = payload?.agentCardUrl as string | undefined;
|
|
281
|
+
if (!url) return { ok: false, message: 'add-agent requires agentCardUrl' };
|
|
282
|
+
return this.hooks.addAgent
|
|
283
|
+
? this.hooks.addAgent(name, url)
|
|
284
|
+
: { ok: false, message: 'add-agent is not wired (client mode not mounted)' };
|
|
285
|
+
}
|
|
286
|
+
case 'remove-agent':
|
|
287
|
+
return this.hooks.removeAgent
|
|
288
|
+
? this.hooks.removeAgent(target)
|
|
289
|
+
: { ok: false, message: 'remove-agent is not wired (client mode not mounted)' };
|
|
290
|
+
case 'server-enable':
|
|
291
|
+
return this.hooks.setServerEnabled
|
|
292
|
+
? this.hooks.setServerEnabled(true)
|
|
293
|
+
: { ok: false, message: 'server control is not wired (server mode not mounted)' };
|
|
294
|
+
case 'server-disable':
|
|
295
|
+
return this.hooks.setServerEnabled
|
|
296
|
+
? this.hooks.setServerEnabled(false)
|
|
297
|
+
: { ok: false, message: 'server control is not wired (server mode not mounted)' };
|
|
298
|
+
case 'server-status':
|
|
299
|
+
return this.hooks.serverStatus
|
|
300
|
+
? Promise.resolve(this.hooks.serverStatus())
|
|
301
|
+
: { ok: false, message: 'server-status is not wired (server mode not mounted)' };
|
|
302
|
+
default:
|
|
303
|
+
return { ok: false, message: `unknown action ${action}` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private closePeerLocal(id: string): ControlResult {
|
|
308
|
+
if (!this.peers.has(id)) return { ok: false, message: `peer ${id} not found` };
|
|
309
|
+
this.removePeer(id);
|
|
310
|
+
return { ok: true, message: `peer ${id} closed` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function peerLabel(ua: string, remote?: string): string {
|
|
315
|
+
// Common A2A client user agents; else show a compact token.
|
|
316
|
+
const lower = ua.toLowerCase();
|
|
317
|
+
if (lower.includes('python-requests') || lower.includes('a2a-python')) return 'Python A2A client';
|
|
318
|
+
if (lower.includes('node')) return 'Node A2A client';
|
|
319
|
+
if (lower.includes('curl')) return 'curl';
|
|
320
|
+
if (lower.includes('mozilla')) return 'Browser';
|
|
321
|
+
const compact = ua.replace(/[^A-Za-z0-9._-]/g, ' ').trim().split(/\s+/).slice(0, 3).join(' ');
|
|
322
|
+
return compact || (remote ?? 'unknown');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A client owned by the plugin entry, tracked for the dashboard. */
|
|
326
|
+
export interface TrackedClient {
|
|
327
|
+
client: A2AClient;
|
|
328
|
+
connectionId: string;
|
|
329
|
+
name: string;
|
|
330
|
+
agentCardUrl: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── process-wide shared registry ───────────────────────────────────────────
|
|
334
|
+
// Multiple dsh-a2a plugin instances (client and/or server) share ONE
|
|
335
|
+
// registry, so the dashboard API shows every connection regardless of which
|
|
336
|
+
// instance observed it. The first `apply` creates it; later instances reuse
|
|
337
|
+
// it and merely attach their own control hooks.
|
|
338
|
+
|
|
339
|
+
let shared: DashboardRegistry | undefined;
|
|
340
|
+
|
|
341
|
+
/** Get the process-wide dashboard registry (creating it on first use). */
|
|
342
|
+
export function getSharedRegistry(): DashboardRegistry {
|
|
343
|
+
if (!shared) shared = new DashboardRegistry();
|
|
344
|
+
return shared;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Test hook: reset the shared registry (and any hooks). */
|
|
348
|
+
export function resetSharedRegistry(): void {
|
|
349
|
+
shared = undefined;
|
|
350
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent2Agent) Protocol v1.0 — errors.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** An error produced by a remote A2A agent (JSON-RPC error object). */
|
|
6
|
+
export interface A2ARemoteErrorInfo {
|
|
7
|
+
code: number;
|
|
8
|
+
message: string;
|
|
9
|
+
data?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Error class for A2A protocol failures, mirroring JSON-RPC error semantics. */
|
|
13
|
+
export class A2AError extends Error {
|
|
14
|
+
readonly code: number;
|
|
15
|
+
readonly data?: unknown;
|
|
16
|
+
/** True when this error came from a remote agent over the wire. */
|
|
17
|
+
readonly remote: boolean;
|
|
18
|
+
|
|
19
|
+
constructor(code: number, message: string, data?: unknown, remote = false) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'A2AError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.data = data;
|
|
24
|
+
this.remote = remote;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toJsonRpc(): { code: number; message: string; data?: unknown } {
|
|
28
|
+
const out: { code: number; message: string; data?: unknown } = {
|
|
29
|
+
code: this.code,
|
|
30
|
+
message: this.message,
|
|
31
|
+
};
|
|
32
|
+
if (this.data !== undefined) out.data = this.data;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static fromJsonRpc(err: A2ARemoteErrorInfo): A2AError {
|
|
37
|
+
return new A2AError(err.code, err.message, err.data, true);
|
|
38
|
+
}
|
|
39
|
+
}
|